Extension Types vs Classes
Quick comparison
| Feature | Extension type | Class |
|---|---|---|
| Type safety | Compile-time | Compile-time + runtime |
| Memory overhead | None (wraps existing value) | Object header + fields |
| Allocation cost | None | One per instance |
| Runtime type | Representation type | Distinct type |
is checks | Check representation type | Check the class itself |
| Pattern matching | Limited | Full support |
| Sealed classes | No | Yes |
| Inheritance | implements interfaces only | Full extends / implements |
| Freezed / codegen | Not compatible | Full support |
| IDE autocomplete | Yes | Yes |
| Refactoring safety | Yes | Yes |
Memory
An extension type is erased at compile time. Wrapping a map in an extension type creates no additional allocation:
extension type User(Map<String, dynamic> _) {
String get email => _['email'] as String;
int get age => _['age'] as int;
}
// At runtime, User IS the map. No second object exists.
final user = User(validatedMap);
A class creates a new heap object. When the data originated from JSON you already have a map in memory, so a class adds a second allocation on top of it:
class User {
final String email;
final int age;
User({required this.email, required this.age});
}
// JSON map still in memory + new User object = two allocations
final user = User(email: map['email'], age: map['age']);
This matters when processing large numbers of objects. For small amounts it is negligible.
Runtime type checking
Extension types are transparent at runtime. The runtimeType is the representation type, not the extension type name:
extension type User(Map<String, dynamic> _) {
String get email => _['email'] as String;
}
final user = User({'email': 'alice@example.com'});
print(user.runtimeType); // Map<String, dynamic> (not User)
user is Map; // true
A class is a distinct runtime type:
class User {
final String email;
User({required this.email});
}
final user = User(email: 'alice@example.com');
print(user.runtimeType); // User
user is User; // true
user is Map; // false
Pattern matching
Pattern matching on an extension type checks the representation type, not the extension type name. It cannot be used in a sealed hierarchy:
extension type User(Map<String, dynamic> _) {
String get email => _['email'] as String;
}
final value = User({'email': 'alice@example.com'});
// Does not distinguish User from any other Map
switch (value) {
case Map():
print('a Map'); // matches
}
Classes support full sealed pattern matching:
sealed class Shape {}
class Circle extends Shape { final double radius; Circle(this.radius); }
class Rectangle extends Shape { final double width, height; Rectangle(this.width, this.height); }
Shape shape = Circle(5);
switch (shape) {
case Circle(:final radius):
print('circle r=$radius');
case Rectangle(:final width, :final height):
print('rect ${width}x$height');
}
Use classes whenever distinct runtime variants and exhaustive pattern matching are needed.
Inheritance and polymorphism
Extension types can implements abstract interfaces but cannot extends a class:
abstract interface class Identifiable {
String get id;
}
extension type User(Map<String, dynamic> _) implements Identifiable {
String get id => _['id'] as String;
String get email => _['email'] as String;
}
extension type Post(Map<String, dynamic> _) implements Identifiable {
String get id => _['id'] as String;
String get title => _['title'] as String;
}
void printId(Identifiable item) => print(item.id);
printId(User({'id': '1', 'email': 'alice@example.com'}));
printId(Post({'id': '2', 'title': 'Hello world'}));
Classes support the full OOP hierarchy:
abstract class Entity {
final String id;
Entity(this.id);
void save();
}
class User extends Entity {
final String email;
User({required String id, required this.email}) : super(id);
@override
void save() { /* ... */ }
}
Serialization
Extension types need no deserialization work: the underlying map is the data. toJson() is a free operation:
extension type User(Map<String, dynamic> data) {
String get email => data['email'] as String;
Map<String, dynamic> toJson() => data;
factory User.fromJson(Map<String, dynamic> json) {
return User(userSchema.parse(json)); // parse() throws on invalid input
}
}
final json = jsonEncode(user.toJson());
final user2 = User.fromJson(jsonDecode(json) as Map<String, dynamic>);
Classes require a fromJson factory and can use codegen (json_serializable, Freezed):
@freezed
class User with _$User {
factory User({
required String email,
required int age,
}) = _User;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}
final json = jsonEncode(user.toJson());
final user2 = User.fromJson(jsonDecode(json) as Map<String, dynamic>);
Classes get copyWith, ==, toString, and union types from Freezed for free. Extension types do not.
Immutability
Extension types wrap a Map<String, dynamic>. If the caller holds a reference to the original map they can mutate it after the extension type is created:
final raw = {'email': 'alice@example.com'};
final user = User(raw);
raw['email'] = 'mutated@example.com'; // the map was mutated from outside
print(user.email); // 'mutated@example.com'
Guard against this by wrapping in an unmodifiable map:
extension type User._(Map<String, dynamic> _) {
User(Map<String, dynamic> map) : this._(Map.unmodifiable(map));
String get email => _['email'] as String;
}
Classes with final fields are immutable by construction:
class User {
final String email;
final int age;
const User({required this.email, required this.age});
// email and age cannot be reassigned
}
Freezed compatibility
Extension types are not compatible with Freezed. They cannot carry code-generation annotations:
// Compile error
@freezed
extension type User(Map<String, dynamic> _) { ... }
Classes get the full Freezed feature set:
@freezed
class User with _$User {
factory User({required String email, required int age}) = _User;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}
final updated = user.copyWith(age: 31); // generated
print(user == updated); // generated equality
Decision guide
Need runtime type distinctions or sealed pattern matching?
Yes: use a class
No:
Need Freezed (copyWith, unions, codegen)?
Yes: use a class
No:
Complex mutable domain logic (state, business rules)?
Yes: use a class
No:
Wrapping validated map data from JSON or an API?
Yes: extension type
No: either works — extension type for simplicity, class for richer features
Summary
| Scenario | Extension type | Class |
|---|---|---|
| Wrapping API or JSON data | Ideal | Works, extra allocation |
| High-volume data processing | Zero extra cost | GC pressure |
| Domain models with business rules | Awkward | Natural |
| Sealed unions and pattern matching | Not supported | Full support |
| Freezed integration | Not compatible | Full support |
| Guaranteed immutability | Manual (Map.unmodifiable) | final fields |
| No build_runner dependency | Yes | Depends on codegen |
Hybrid approach
Validate with Zema, then construct a Freezed class. This separates input validation (Zema) from domain modeling (Freezed):
final userSchema = z.object({
'email': z.string().email(),
'age': z.integer().gte(18),
});
@freezed
class User with _$User {
factory User({required String email, required int age}) = _User;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}
final result = userSchema.safeParse(apiResponse);
switch (result) {
case ZemaSuccess(:final value):
final user = User.fromJson(value);
// validated input + Freezed features: copyWith, ==, toString
case ZemaFailure(:final errors):
// handle errors
}
Next steps
- Creating Extension Types: field accessors, methods, operators, and factories
- What are Extension Types: zero-cost abstraction and compile-time erasure
- Objects:
z.object()andz.objectAs()
Creating Extension Types
Define extension types for Zema-validated maps: getters, optional fields, computed properties, nested types, methods, operators, and real-world examples.
Extension Type Best Practices
Patterns, anti-patterns, and conventions for defining and using extension types with Zema-validated maps.