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.
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:
class UserId { final String value; ... }) gives you type safety, but every ID you touch is now a heap allocation. In a hot path — say, mapping a list of thousands of records from a Firestore query — that's real pressure on the allocator and the garbage collector. On mobile, where you're fighting for every frame in a 60fps or 120fps budget, GC pauses are not free.typedef UserId = String) is pure documentation. UserId and String are the same type. The compiler will still happily swap your arguments. It's a comment that lies about being a type.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.
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 OrderIdBoth 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'.
const keyword and why it mattersI 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.
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.99Now 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.
Here's the subtlety that trips up everyone the first time. By default, an extension type is opaque — UserId 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: Email → String works implicitly, but String → Email 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:
implements) for identifiers — UserId, OrderId. You almost never want to accidentally treat an ID as a general string.implements) for values that genuinely are the underlying type but carry extra invariants or helpers — an Email, a NonEmptyString, or a Cents amount that you want usable as a plain int in some contexts.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.
| 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 |
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 goneThe 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:
is UserId checks or expect runtimeType to say UserId. Pattern matching on the type in a switch won't distinguish it from the underlying String either.String, then wrap it: UserId(json['userId'] as String). The type discipline lives inside your code, not at the serialization edge.List<UserId> and a List<OrderId> are both List<String> at runtime, so a bad cast can smuggle the wrong type across without a runtime error.Treat extension types as compile-time contracts, not runtime guards. If you need a real runtime-checkable invariant, you need a real class.
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.
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.
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.
You don't have to convert everything at once. The migration path I've used:
UserId, OrderId, etc.) with opaque semantics.String needs an explicit wrap, and each one is a spot where the swap bug could have lived.Because the runtime representation never changes, this migration is behavior-preserving — you're adding compile-time constraints, not altering what the code does.
implements deliberately. Keep identifiers opaque so you can't treat them as generic strings; make value objects like Email transparent when they genuinely are the underlying type.const unless you have a reason not to — it unlocks const contexts for free.is and runtimeType see the representation type, not your wrapper. Convert and validate at boundaries; never rely on extension types as runtime guards.is/runtimeType to actually mean something.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.