devShakib

Dart Extension Types: The Zero-Cost UserId You Should Be Using

Dart extension types give you zero cost, type safe wrappers for IDs and value objects — no heap allocation. Learn opaque vs transparent, smart constructors, and gotchas.

Every Dart codebase I've worked on eventually accumulates a swamp of String and int arguments that all look identical to the compiler but mean wildly different things. A userId, an orderId, a productSku — all String, all interchangeable, all one careless argument swap away from a production incident that no test caught. Dart's extension types finally fix this without the runtime cost that made me avoid wrapper classes for years. If you're modeling a domain in Dart or Flutter and you're tired of primitive obsession, this is one of the highest-leverage features Dart 3 shipped.

The problem: primitives lie about what they are

Consider a function signature I've written a hundred variations of:

Future<void> transferOwnership(String orderId, String userId) { ... }

Nothing stops a caller from writing transferOwnership(userId, orderId) — arguments reversed, types match, compiler happy, and now you're transferring the wrong thing. On our commerce backend at Shpper, this exact class of bug (an ID passed where a different ID was expected) was one of the more annoying categories of defect, precisely because it's invisible in code review and only surfaces at runtime with real data.

This is what the refactoring literature calls primitive obsession: leaning on String, int, and double to represent domain concepts that deserve their own identity. The symptom is always the same — validation logic scattered everywhere, arguments that can be silently transposed, and a compiler that can't help you because you never told it what these values actually mean.

The classic fixes each have a real cost:

Extension types are the answer that was missing: the compile-time safety of the wrapper class with the runtime footprint of the typedef, which is to say none.

What a Dart extension type actually is

An extension type is a compile-time-only wrapper over an existing "representation type." At runtime, a UserId is a String — there is no object, no field, no allocation. The wrapper exists purely in the type system and is erased during compilation. If you've heard the phrase zero-cost abstraction from Rust or C++, this is Dart's version of it applied to domain modeling.

extension type const UserId(String value) {  bool get isValid => value.isNotEmpty && value.length <= 128;}extension type const OrderId(String value) {}

That (String value) is the representation declaration — it names the underlying type and the getter you'll use to reach it. Now the earlier bug is a compile error:

final user = UserId('u_123');final order = OrderId('o_456');transferOwnership(order, user); // ✓ compilestransferOwnership(user, order); // ✗ compile error — UserId is not OrderId

Both UserId and OrderId wrap String, but they are distinct, non-interchangeable types to the compiler. That's the whole game. You've given the compiler enough information to catch the swap, and it costs nothing at runtime because UserId('u_123') compiles down to just the string 'u_123'.

The const keyword and why it matters

I write extension type const deliberately. It lets you construct instances in const contexts, which matters when these IDs show up in const collections, switch cases, or default parameter values. It's cheap insurance — add it unless you have a specific reason not to.

Adding behavior with members

An extension type isn't just a name — you can hang getters, methods, and operators off it, and they cost nothing extra because they resolve statically:

extension type const Cents(int value) {  Cents operator +(Cents other) => Cents(value + other.value);  double get dollars => value / 100;  String get formatted => '\$${dollars.toStringAsFixed(2)}';}final total = Cents(1599) + Cents(400); // Cents(1999)print(total.formatted);                 // $19.99

Now money arithmetic is type-checked. You physically cannot add Cents to a raw int or to a UserId, and the formatted helper lives with the type instead of floating around in some utils.dart.

Transparent vs. opaque: the part people miss

Here's the subtlety that trips up everyone the first time. By default, an extension type is opaqueUserId does not expose String's methods, and you can't pass a UserId where a String is expected. That's usually what you want. It's the whole point: UserId shouldn't be .toUpperCase()-able or concatenatable like a generic string.

But you can opt into transparency by implementing the representation type:

extension type const Email(String value) implements String {  bool get isValid => value.contains('@');}

With implements String, an Email is-a String for the type system — you can pass it to anything expecting a String, and it inherits every String method. This is a one-way door: EmailString works implicitly, but StringEmail still requires the explicit Email(...) constructor. That asymmetry is exactly right. You want to control where raw strings become validated emails, but once validated, an email should be usable anywhere a string is.

You can also implements a supertype of the representation to expose only part of the API surface. For example, an extension type over int could implements num or implements Object to control precisely how much of the underlying type leaks through.

My rule of thumb:

Where extension types genuinely beat the alternatives

I reach for extension types specifically when all three of these are true:

That third point is where they earn their keep over a class. Mapping a Firestore snapshot of 5,000 orders into domain objects, each with three ID fields, means 15,000 wrapper allocations with classes — and zero with extension types. On the client side of a Flutter app, that's fewer objects for the GC to trace, which translates directly into fewer jank spikes when you're scrolling a long list built from that data.

Extension type vs. class vs. typedef — a quick comparison

| Approach | Compile-time safety | Runtime allocation | is / runtimeType works | Best for |

| --- | --- | --- | --- | --- |

| typedef UserId = String | None | None | No (it is String) | Nothing — it's a lie |

| class UserId | Yes | One object per value | Yes | Multi-field objects, runtime checks |

| extension type UserId | Yes | None (erased) | No | IDs, money, validated primitives in hot paths |

The catch that will bite you: no runtime identity

Because the wrapper is erased, extension types provide zero runtime safety. This is the single most important thing to internalize before you deploy them:

final id = UserId('u_123');print(id is String);   // true — at runtime it IS a Stringprint(id.runtimeType); // Stringdynamic d = id;d is UserId;           // false — the type is gone

The safety is entirely static. The moment a value passes through dynamic, Object, JSON decoding, or any reflection-like boundary, the extension type is invisible. So:

Treat extension types as compile-time contracts, not runtime guards. If you need a real runtime-checkable invariant, you need a real class.

Serialization and json_serializable

Because the type is erased, extension types play nicely with codegen at the edges — but you have to be explicit. A typical pattern is a raw string field in your DTO plus a getter that wraps it:

extension type const UserId(String value) {}class UserDto {  UserDto(this._id);  final String _id;  UserId get id => UserId(_id); // wrap at the boundary}

Keep the wrapping and unwrapping in the model layer. Never let a UserId reach jsonEncode expecting special treatment — it's just a String on the wire, which is exactly what you want.

A pattern I use: IDs plus a smart constructor

For anything with a validation rule, I pair the extension type with a factory that fails loudly, keeping the raw constructor for trusted (already-validated) sources like the database:

extension type const Slug._(String value) {  factory Slug(String raw) {    final normalized = raw.trim().toLowerCase();    if (!RegExp(r'^[a-z0-9-]+$').hasMatch(normalized)) {      throw ArgumentError.value(raw, 'raw', 'Not a valid slug');    }    return Slug._(normalized);  }}

The private ._ constructor keeps the unchecked path out of general reach, while the factory is the front door. This is the closest extension types get to a class's encapsulation — and it's plenty for domain modeling. If you prefer errors as values over exceptions, return a Result/Either from a static tryParse instead of throwing; the shape is identical, only the failure channel changes.

A note on equality

Equality follows the representation type, which is almost always what you want. Two UserId('u_123') values are equal and hash the same because the underlying String does — so they work correctly as Map keys and in Sets with no extra code. If you implements the representation type, you also inherit its == and hashCode. Only reach for a custom operator == if your extension type deliberately needs equality that differs from the primitive it wraps, which is rare.

Migrating an existing codebase incrementally

You don't have to convert everything at once. The migration path I've used:

Because the runtime representation never changes, this migration is behavior-preserving — you're adding compile-time constraints, not altering what the code does.

Key takeaways

Reach for extension types when you want a primitive to have a real, distinct identity to the compiler with no allocation overhead. Use implements deliberately, wrap at the edges, and never let a typedef masquerade as type safety again — it's a lie the compiler will happily let you believe until it's 2 a.m. and the wrong ID is in production.