In Dart, what do `is`, `as` and pattern matches check when a value has an extension type, and why is that weaker than a wrapper class?
answer
- erased at compile time
- checks the representation object
- is UserId behaves like is int
- casts skip constructors
- List<UserId> equals List<int>
basics
~20 sExtension types are erased, so is, as, switch and if-case checks test the representation object's runtime type. UserId(7) is int and 7 is UserId are both true, and 7 as UserId skips any constructor validation — protection that exists only at compile time.
solid answer
~40 sAt runtime an extension type is its representation type: dart.dev says there is absolutely no trace of it. So type tests, casts and pattern matches evaluate against the underlying object. With `extension type UserId(int value)` and `OrderId(int value)`, `UserId(7) is int` is true, `7 is UserId` is true, `UserId(7) is OrderId` is true, and `<UserId>[] is List<int>` is true. A cast or pattern such as `json['id'] as UserId` only changes the static type; it does not run a constructor, so any validation in `UserId`'s factory is bypassed. A wrapper class is a real runtime type: `is` distinguishes kinds and every instance passed through its constructor. That makes the extension type an unsafe abstraction: great for compile-time separation at zero cost, but not a runtime guarantee.
code
dart · 13 linesextension type UserId(int value) {}
extension type OrderId(int value) {}
void main() {
final user = UserId(7);
print(user is int); // true: the value is the int 7
print(7 is UserId); // true: checked as 'is int'
print(user is OrderId); // true: both erase to int
print(<UserId>[] is List<int>); // true: type arguments erase
final forged = -1 as UserId; // no constructor runs
print(forged.value); // -1
}go deeper
Remember that extension types disappear at runtime, so is and as checks see the underlying int.
Explain that is, as, switch and if-case all test the representation object, and that casts and patterns never run the constructor.
Construct extension-type ids explicitly at JSON boundaries and choose wrapper classes where runtime guarantees or kind-based branching are required.
Decide where compile-time-only typing is acceptable across the codebase and where runtime-enforced invariants justify the allocation cost.
## Erasure in one sentence An **extension type** exists only for the compiler. The SDK changelog for Dart 3.3 states it plainly: at run time, an extension type is erased to the corresponding representation type, and even the type itself is replaced (`E == R` is true). dart.dev adds that there is "absolutely no trace" of it at run time. That makes every runtime operation that looks at types behave as if the extension type were not there. ## What the runtime checks actually test Assume: ```dart extension type UserId(int value) {} extension type OrderId(int value) {} ``` | Expression | Result | Why | |---|---|---| | `UserId(7) is int` | `true` | the value is the `int` 7 | | `7 is UserId` | `true` | `is UserId` is checked as `is int` | | `UserId(7) is OrderId` | `true` | both erase to `int` | | `<UserId>[] is List<int>` | `true` | type arguments erase too | | `UserId(7) == OrderId(7)` | `true` | the `==` of `int` runs on two equal ints | dart.dev lists the operations affected: dynamic type tests (`e is T`), casts (`e as T`), and pattern matches in `switch` and `if (e case ...)` all evaluate against the representation object, both when the static type of `e` is an extension type and when the test names an extension type. ## Casts and patterns skip constructors This is the subtle part. Suppose `UserId` validates: ```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); } } ``` Then: ```dart final a = UserId(-1); // throws: validation runs final b = -1 as UserId; // succeeds: only the static type changes final c = jsonDecode('{"id": -1}')['id'] as UserId; // succeeds too if (-1 case UserId id) print(id.value); // prints -1 ``` dart.dev: when a value gets an extension type through a cast or a pattern match, it is still the same object and **no constructor is invoked**; if you want validation, write an explicit constructor call such as `UserId(i)`. Practical rules for a Flutter API layer: 1. At **deserialisation boundaries**, construct ids explicitly (`UserId(json['id'] as int)`) rather than casting to the extension type. 2. Do not rely on `is UserId` or a `case UserId()` to tell id kinds apart at runtime; both match any `int`. 3. Treat constructor validation as advisory: it catches honest mistakes, not deliberate casts. ## Contrast with a wrapper class ```dart final class UserIdBox { UserIdBox(this.value) { if (value <= 0) throw ArgumentError.value(value); } final int value; } ``` | Property | Extension type `UserId` | Wrapper class `UserIdBox` | |---|---|---| | Separate static type | yes | yes | | Separate runtime type | no | yes | | `is` distinguishes from `int` and other ids | no | yes | | Validation cannot be bypassed by a cast | no | yes (`7 as UserIdBox` throws) | | Allocation per value | none | one object per id | | `==` and `hashCode` | the representation's | must be written (or come from a package) | dart.dev summarises the trade-off: a real wrapper class can encapsulate the wrapped object, whereas an extension type is just a compile-time view on it; the wrapper is safer, the extension type avoids the wrapper objects and can greatly improve performance. ## Choosing - **Extension type**: large volumes (lists of ids from an API), interop handles, units — cases where compile-time separation is what you need and allocation matters. - **Wrapper class**: ids that cross untyped boundaries often, values whose invariants must hold at runtime, or code that must branch on the kind of id with `is` or a sealed hierarchy. ## Interview framing State "erased at compile time", give `7 is UserId == true` as the memorable example, then explain the consequence nobody expects: casts and patterns never run the constructor.
- In Dart, how should JSON parsing create extension-type ids so validation actually runs?Call the constructor explicitly: `UserId(json['id'] as int)`. Casting the decoded value with `as UserId` only changes its static type and never runs the constructor, so any check inside it is skipped.
- In Dart, can a sealed class hierarchy and extension types be combined to branch on id kinds at runtime?Not through the extension types: `case UserId()` matches any `int`, so it cannot tell a user id from an order id. Branching on kind at runtime needs real classes, such as final wrapper classes under a sealed supertype.
saying these in an interview costs you the question
- 7 is UserId is false because UserId is a separate type.
- A cast to an extension type runs its constructor.
- Extension types keep a hidden runtime tag for is checks.
- List<UserId> and List<int> are different types at runtime.
- Pattern matching on an extension type distinguishes UserId from OrderId.