skip to content

In Dart, how would you use an extension type so user ids and order ids, both plain ints, cannot be mixed up, and what does it cost at runtime?

level: seniorimportance: should knowfreq 30%

answer

  1. extension type UserId(int value)
  2. representation type
  3. distinct static type
  4. no implements int: opaque
  5. erased: zero allocation

basics

~20 s

Declare extension type UserId(int value) {} and extension type OrderId(int value) {}. Each is a distinct static type over int, so passing a UserId where an OrderId or int is expected is a compile error, yet at runtime both are just int: no wrapper object.

solid answer

~40 s

An extension type is a compile-time view of an existing **representation type**. `extension type UserId(int value) {}` declares a new static type whose values are `int`s, with a constructor `UserId(7)` and a getter `value`. Without an `implements` clause it is **opaque**: `int` members are not available on it, it is not assignable to `int`, and `UserId` and `OrderId` are not assignable to each other, so `cancelOrder(UserId(7))` fails to compile. Calls on it resolve statically, like extension methods, and the type is erased during compilation, so a `List<UserId>` is a `List<int>` at runtime with no per-element allocation. That makes it cheaper than a wrapper class. Adding `implements int` would make it transparent and let ids flow into `int` code, which defeats the purpose here.

code

dart · 20 lines
dart
extension type UserId(int value) {
  String toPath() => '/users/$value';
}

extension type OrderId(int value) {
  String toPath() => '/orders/$value';
}

Future<void> cancelOrder(OrderId id) async {
  print('DELETE ${id.toPath()}');
}

Future<void> main() async {
  final user = UserId(7);
  // await cancelOrder(user); // error: UserId can't be assigned to OrderId
  // await cancelOrder(7);    // error: int can't be assigned to OrderId
  // int raw = user;          // error: UserId isn't an int statically
  await cancelOrder(OrderId(9)); // DELETE /orders/9
  print(user.value + 1);         // 8: value is typed int
}

go deeper

for a junior

Recall that extension type UserId(int value) creates a separate compile-time type over int, so ids of different kinds cannot be swapped.

for a middle

Explain the representation type, the implicit constructor and getter, and how implements switches between an opaque and a transparent extension type.

for a senior

Choose between extension types and wrapper classes by weighing zero allocation against the lack of runtime guarantees, and put validation in constructors.

for a principal

Decide where a codebase standardises typed ids and units, balancing compile-time safety, JSON boundaries and the cost of a type that erases at runtime.

## The problem: primitive ids are interchangeable A Flutter app's API layer often carries several kinds of numeric ids: user ids, order ids, product ids. As plain `int`s they are interchangeable, so nothing stops this bug: ```dart Future<void> cancelOrder(int orderId) async { /* ... */ } cancelOrder(user.id); // compiles, cancels the wrong thing ``` A classic fix is a **wrapper class** (`class UserId { final int value; ... }`), which is type-safe but allocates an object per id and needs `==` and `hashCode` written. Dart 3.3 added **extension types**, which give the type safety without the allocation. ## Declaring the extension types ```dart extension type UserId(int value) {} extension type OrderId(int value) {} ``` Each declaration: - names a new **static type** (`UserId`), not an alias for `int`; - declares the **representation type** `int` and a **representation variable** `value`, with an implicit getter `int get value` and an implicit constructor `UserId(int value)`; - has an empty body here, so it exposes **no `int` members** at all. In Dart 3.13 the parenthesised clause is a **primary constructor** whose single parameter is always declaring, so the syntax above is unchanged. ## What the compiler now rejects ```dart Future<void> cancelOrder(OrderId id) async { /* ... */ } final user = UserId(7); cancelOrder(user); // error: UserId is not an OrderId cancelOrder(7); // error: int is not an OrderId int raw = user; // error: UserId is not assignable to int user + 1; // error: UserId has no operator + cancelOrder(OrderId(9)); // OK print(user.value); // OK: 7, typed as int ``` Because the extension type does not implement `int`, it is **opaque**: a distinct type with only the members you declare. You can add exactly the ones that make sense, e.g. `bool get isGuest => value == 0;` or `String toPath() => '/users/$value';`. ## `implements`: opaque versus transparent | Declaration | `int` members available? | Assignable to `int`? | Use | |---|---|---|---| | `extension type UserId(int value) {}` | no | no | ids that must not mix | | `extension type UserId(int value) implements int {}` | yes | yes | extend `int` with extras | | `extension type UserId(int value) implements Object {}` | only `Object`'s | to `Object` | non-nullable marker | An extension type may implement only its representation type, a supertype of it, or another extension type on a compatible representation. For ids, stay opaque: `implements int` would let a `UserId` flow into any `int` parameter, including `cancelOrder(int)`. ## What it costs at runtime - **No allocation.** Extension types are erased during compilation; a `UserId` is the `int` itself. The SDK changelog calls them zero-cost wrappers, and dart.dev notes they avoid the expense of wrapping lots of objects. - **No dynamic dispatch.** Member calls resolve at compile time from the static type, like extension methods. - **Collections are free.** `List<UserId>` is exactly `List<int>` at runtime, so decoding a thousand ids allocates no wrappers. ## Construction and validation The representation clause is a constructor, and you can add others or hide it: ```dart extension type const UserId._(int value) { factory UserId(int value) { if (value <= 0) throw ArgumentError.value(value, 'value', 'must be positive'); return UserId._(value); } } ``` Any validation lives in constructors. A cast such as `7 as UserId` does **not** call them, which is the main weakness of the abstraction. ## Limits worth knowing 1. Extension types cannot declare **instance fields** (the representation is the only state) or **abstract members**. 2. They cannot use **`extends`** or **`with`**, and class modifiers do not apply to them. 3. Runtime checks see the representation: `UserId(7) is int` is `true`. ## When to choose it over a wrapper class Choose an extension type for high-volume, performance-sensitive values where compile-time separation is enough — ids, units, interop handles. Choose a wrapper class when you need runtime guarantees, such as `is UserId` distinguishing a user id from an order id, or validation that no cast can bypass.

  • In Dart, why not just use `typedef UserId = int;` for ids?
    A typedef is only another name for the same type: a `UserId` typedef is an `int`, so `cancelOrder(userId)` with an `int` parameter compiles and nothing is separated. An extension type declares a new static type that is not assignable to or from `int` unless it implements it.
  • In Dart, when would you add `implements int` to an extension type?
    When you want to extend `int` rather than hide it: the extension type keeps every `int` member and stays assignable to `int`, and adds its own members on top. That is useful for units or helpers, but wrong for ids that must not mix, because a transparent `UserId` can flow into any `int` parameter.

An extension type is a coloured label stuck on the same box: the warehouse scanner (the compiler) refuses to put a blue-labelled box on the red shelf, but the box inside is unchanged and the label costs nothing to ship.

saying these in an interview costs you the question

  • An extension type allocates a small wrapper object for each value.
  • typedef UserId = int gives the same type safety as an extension type.
  • An extension type without implements still exposes all int members.
  • UserId and OrderId over int are interchangeable at compile time.
  • Extension types can declare extra instance fields for metadata.