Zema Logo
Extension Types

What are Extension Types?

Dart 3 extension types explained: zero-cost type-safe wrappers over existing types, and how they integrate with Zema-validated data.

Extension types are a Dart 3.3 feature that wraps an existing type with a new compile-time type. The wrapper disappears at runtime: there is no additional allocation, no object header, no garbage collection pressure. You get type safety and IDE autocomplete at zero cost.

The problem with raw maps

JSON data in Dart is typically Map<String, dynamic>. Access is untyped and error-prone:

final user = {'id': 1, 'email': 'alice@example.com', 'age': 30};

print(user['emial']);       // typo: returns null, no compile error
print(user['age'] + 'x');  // runtime error: no type check
user['???']                 // IDE cannot suggest valid keys

Traditional solution: classes

A regular class provides type safety and autocomplete, but introduces runtime overhead: a heap allocation per instance, an object header, garbage collection work.

class User {
  final int id;
  final String email;
  final int age;

  User({required this.id, required this.email, required this.age});
}

final user = User(id: 1, email: 'alice@example.com', age: 30);
print(user.email);  // typed, IDE-assisted

When you are already working with a Map<String, dynamic> from JSON or Zema, creating a class means allocating a second object just to provide typed access to data that already exists in memory.

Extension type solution

An extension type wraps the map without allocating anything extra. At runtime it is just the map:

extension type User(Map<String, dynamic> _) {
  int    get id    => _['id']    as int;
  String get email => _['email'] as String;
  int    get age   => _['age']   as int;
}

final user = User({'id': 1, 'email': 'alice@example.com', 'age': 30});

print(user.email);  // 'alice@example.com' (typed)
print(user.emial);  // compile error: undefined getter
print(user.age + 1); // 31 (int, not dynamic)

At runtime user is the Map<String, dynamic>. No second object is created.

How extension types work

Compile-time only

The extension type exists only during compilation. The Dart compiler checks property access, infers types, and provides autocomplete. After compilation the type is erased and the underlying representation takes its place:

// Written as:
User user = User({'email': 'alice@example.com'});
print(user.email);

// Compiled to (approximately):
Map<String, dynamic> user = {'email': 'alice@example.com'};
print(user['email']);

user.runtimeType will report Map<String, dynamic>, not User.

The representation type

The type in parentheses after the name is the representation type: the actual runtime value.

extension type User(Map<String, dynamic> _) {
  //               ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  //               representation type: Map<String, dynamic>
  //               _ is the field that holds it
  String get email => _['email'] as String;
}

Zero-cost

Because the extension type adds no allocation, using it on data you already have in a map is free:

// Without extension type (untyped)
final map = jsonDecode(response.body) as Map<String, dynamic>;
print(map['email']);  // no safety

// With extension type (typed, same memory)
final user = User(jsonDecode(response.body) as Map<String, dynamic>);
print(user.email);   // compile-time checked, zero extra cost

Comparison with alternatives

Extension type vs class

ClassExtension type
Type safetyYesYes
IDE autocompleteYesYes
Runtime allocationOne object per instanceNone (wraps existing value)
runtimeTypeThe class itselfThe representation type
is checksWorkAlways check against the representation type
Inheritanceextends, implementsimplements interfaces only

Extension type vs typedef

typedef is an alias: it provides no type safety.

typedef UserId = int;

UserId id = 123;
int n = 456;
id = n;  // allowed: typedef is just an alias

An extension type is a distinct type:

extension type UserId(int _) {
  int get value => _;
}

UserId id = UserId(123);
int n = 456;
id = n;  // compile error: not the same type

Extension type vs extension method

An extension method adds members to an existing type but does not create a new type. It applies to all values of that type regardless of meaning:

extension on Map<String, dynamic> {
  String get email => this['email'] as String;
}

final anyMap = {'foo': 'bar'};
print(anyMap.email);  // compiles (unsafe: applies to every map)

An extension type creates a distinct named type. The accessors are only available on values explicitly typed as that extension type:

extension type User(Map<String, dynamic> _) {
  String get email => _['email'] as String;
}

final anyMap = <String, dynamic>{'foo': 'bar'};
anyMap.email;  // compile error: not a User

final user = User({'email': 'alice@example.com'});
user.email;    // fine

Integration with Zema

Zema validates data and returns Map<String, dynamic>. Extension types give you typed access to that validated map.

Wrap the result manually

final userSchema = z.object({
  'id': z.integer(),
  'email': z.string().email(),
  'age': z.integer().gte(18),
});

extension type User(Map<String, dynamic> _) {
  int get id => _['id']    as int;
  String get email => _['email'] as String;
  int get age => _['age']   as int;
}

final result = userSchema.safeParse(apiData);

switch (result) {
  case ZemaSuccess(:final value):
    final user = User(value);   // wrap validated map in extension type
    print(user.email);          // String (typed)
    print(user.age);            // int (typed)
  case ZemaFailure(:final errors):
    for (final issue in errors) {
      print('${issue.pathString}: ${issue.message}');
    }
}

Use z.objectAs() for inline construction

z.objectAs() validates the map and immediately passes it to a constructor function. This keeps the extension type construction co-located with the schema:

final userSchema = z.objectAs(
  {
    'id': z.integer(),
    'email': z.string().email(),
    'age': z.integer().gte(18),
  },
  User.new,  // User(Map<String, dynamic>) constructor
);

// result is ZemaResult<User> directly
final result = userSchema.safeParse(apiData);

switch (result) {
  case ZemaSuccess(:final value):
    print(value.email);  // value is User, not Map
  case ZemaFailure(:final errors):
    ...
}

Limitations

No is checks at runtime. user is User is always false: the runtime type is Map<String, dynamic>. Use user is Map<String, dynamic> instead.

No runtime type distinction. Two extension types wrapping Map<String, dynamic> are indistinguishable at runtime. Pattern matching against the extension type name does not work.

Manual casts in getters. Accessing map values requires explicit casts (_['email'] as String). The validation schema guarantees the casts are safe, but they are not checked again at access time.

Not suitable for complex inheritance. Extension types support implements (interfaces) only. They cannot extends a class.

When to use extension types

Use them when you have a Map<String, dynamic> from Zema, JSON, or any other source and want typed, IDE-assisted access to its fields without paying the cost of a second allocation.

Do not use them when you need runtime type checks, pattern matching against the type name, or complex class hierarchies.

Next steps

  • Objects: z.object() and z.objectAs() for validated map schemas
  • Modifiers: .brand<B>() for nominal typing with a real compile-time distinction
Copyright © 2026