What are Extension Types?
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
| Class | Extension type | |
|---|---|---|
| Type safety | Yes | Yes |
| IDE autocomplete | Yes | Yes |
| Runtime allocation | One object per instance | None (wraps existing value) |
runtimeType | The class itself | The representation type |
is checks | Work | Always check against the representation type |
| Inheritance | extends, implements | implements 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
Error Handling
ZemaIssue structure, error codes, path format, List<ZemaIssue> extensions, ZemaException, custom error messages, and i18n.
Creating Extension Types
Define extension types for Zema-validated maps: getters, optional fields, computed properties, nested types, methods, operators, and real-world examples.