Extension Type Best Practices
Keep getters simple
Getters access already-validated data. They should cast and return, not validate.
// simple cast
String get email => _['email'] as String;
// nullable optional
String? get bio => _['bio'] as String?;
// transform to richer type when schema stores as string
DateTime get createdAt => DateTime.parse(_['createdAt'] as String);
// computed from other getters
String get displayName => '$firstName $lastName';
Do not repeat validation logic that the schema already performs:
// wrong: validates in getter
String get email {
final e = _['email'] as String;
if (!e.contains('@')) throw FormatException('Invalid email');
return e;
}
The schema (z.string().email()) already guarantees the value is a valid email when the extension type is constructed from a parsed map. Adding a second check duplicates logic and can only throw on values the schema already accepted.
Expensive computation belongs in a method, not a getter:
// wrong: heavy computation in getter
String get renderedBio => markdownToHtml(_['bio'] as String);
// correct: method signals the cost
String renderBio() => markdownToHtml(_['bio'] as String);
Use nullable types for optional fields
Fields validated with .optional() may be absent or null. Reflect that in the getter return type:
final userSchema = z.object({
'name': z.string(),
'bio': z.string().optional(),
'age': z.integer().optional(),
});
extension type User(Map<String, dynamic> _) {
String get name => _['name'] as String;
String? get bio => _['bio'] as String?;
int? get age => _['age'] as int?;
}
Avoid silently substituting defaults at the getter level:
// wrong: hides whether bio was provided or absent
String get bio => (_['bio'] as String?) ?? '';
int get age => (_['age'] as int?) ?? 0;
A caller receiving 0 cannot tell whether the user set their age to 0 or whether the field was missing. Return null and let the caller decide.
Use factory constructors for construction
Factory constructors validate data at the point of creation using parse(), which throws ZemaException on invalid input:
final _userSchema = z.object({
'email': z.string().email(),
'name': z.string().min(2),
});
extension type User(Map<String, dynamic> _) {
String get email => _['email'] as String;
String get name => _['name'] as String;
factory User.create({required String email, required String name}) {
final data = {'email': email, 'name': name};
return User(_userSchema.parse(data)); // throws ZemaException if invalid
}
factory User.placeholder() {
return User({'email': 'placeholder@example.com', 'name': 'Unknown'});
}
}
final user = User.create(email: 'alice@example.com', name: 'Alice');
Return new instances for updates
The underlying map should not be mutated. Methods that change data return a new extension type instance built from a spread copy:
extension type User(Map<String, dynamic> _) {
String get name => _['name'] as String;
String get email => _['email'] as String;
User withName(String newName) => User({..._, 'name': newName});
User withEmail(String newEmail) => User({..._, 'email': newEmail});
}
final user = User({'name': 'Alice', 'email': 'alice@example.com'});
final updated = user.withName('Bob');
print(user.name); // 'Alice' (original unchanged)
print(updated.name); // 'Bob'
Mutating the map directly makes state changes invisible to callers who hold a reference to the original:
// wrong: direct map mutation (compiles inside the extension type body since _ is accessible there)
void setName(String newName) {
_['name'] = newName; // mutates in place, affects all holders of the map
}
Add computed properties
Getters can derive values from other fields:
extension type User(Map<String, dynamic> _) {
String get firstName => _['firstName'] as String;
String get lastName => _['lastName'] as String;
String get email => _['email'] as String;
String get fullName => '$firstName $lastName';
String get displayName => fullName.trim().isEmpty ? email : fullName;
bool get isAdmin => ((_['roles'] as List?)?.contains('admin')) ?? false;
}
Computed properties encapsulate logic, reduce duplication, and keep call sites readable.
Protect against external map mutation
An extension type wraps the map by reference. A caller who holds the original map can mutate it after the extension type is constructed:
final raw = {'email': 'alice@example.com'};
final user = User(raw);
raw['email'] = 'mutated@example.com';
print(user.email); // 'mutated@example.com'
Guard against this with a private primary constructor and a public constructor that wraps in Map.unmodifiable:
extension type User._(Map<String, dynamic> _) {
User(Map<String, dynamic> data) : this._(Map.unmodifiable(data));
String get email => _['email'] as String;
}
final raw = {'email': 'alice@example.com'};
final user = User(raw);
raw['email'] = 'mutated@example.com'; // raw still mutable
print(user.email); // 'alice@example.com' (unaffected)
Use this pattern when correctness guarantees around the data are important: authentication tokens, payment data, public API types.
Use typed lists
Wrap list values in List<T>.from() to get a typed, defensive copy:
extension type User(Map<String, dynamic> _) {
List<String> get tags => List<String>.from(_['tags'] as List);
List<String> get roles => List<String>.from(_['roles'] as List);
List<int> get scores => List<int>.from(_['scores'] as List);
}
An untyped List or a direct cast to List<String> on a JSON-decoded list may fail at runtime or allow elements of the wrong type.
Document non-obvious transformations
When a getter converts between representations, a comment on the expected schema type prevents confusion:
extension type Event(Map<String, dynamic> _) {
/// Schema: `z.string()`. Parses ISO 8601 string to DateTime.
DateTime get scheduledAt => DateTime.parse(_['scheduledAt'] as String);
/// Schema: `z.integer()`. Converts duration in seconds to Duration.
/// Example: 3600 -> Duration(hours: 1).
Duration get duration => Duration(seconds: _['durationSeconds'] as int);
}
If the schema already produces a DateTime (z.dateTime()), the getter just casts:
/// Schema: `z.dateTime()`. Already a DateTime, no parsing needed.
DateTime get scheduledAt => _['scheduledAt'] as DateTime;
Provide toJson()
Expose the underlying map for serialization:
extension type User(Map<String, dynamic> data) {
String get email => data['email'] as String;
Map<String, dynamic> toJson() => data;
Map<String, dynamic> copy() => Map.from(data);
}
final json = jsonEncode(user.toJson());
Const constructors
When the representation type supports const (primitive types, String), mark the extension type as const to enable compile-time constants:
extension type const UserId(String _) {
String get value => _;
}
const adminId = UserId('admin-123'); // compile-time constant
Do not rely on runtime type checks
Extension types are erased at runtime. The is check tests the representation type (Map), not the extension type name:
final user = User({'email': 'alice@example.com'});
user is User; // always false at runtime
user is Map<String, dynamic>; // true
To check whether a value conforms to a schema, use safeParse():
void processData(dynamic data) {
final result = userSchema.safeParse(data);
switch (result) {
case ZemaSuccess(:final value):
final user = User(value);
// work with user
case ZemaFailure(:final errors):
// handle validation failure
}
}
Naming
Use specific, noun-based names that identify the domain concept:
// correct: clear domain names
extension type UserProfile(Map<String, dynamic> _) { ... }
extension type BlogPost(Map<String, dynamic> _) { ... }
extension type ProductReview(Map<String, dynamic> _) { ... }
// avoid: generic names that convey no domain meaning
extension type Data(Map<String, dynamic> _) { ... }
extension type Item(Map<String, dynamic> _) { ... }
Organise related types together
Group extension types by domain in dedicated files:
// user_models.dart
extension type User(Map<String, dynamic> _) { ... }
extension type UserProfile(Map<String, dynamic> _) { ... }
extension type UserSettings(Map<String, dynamic> _) { ... }
// product_models.dart
extension type Product(Map<String, dynamic> _) { ... }
extension type ProductReview(Map<String, dynamic> _) { ... }
extension type ProductVariant(Map<String, dynamic> _) { ... }
Real-world example: repository layer
A full pattern combining schema, extension type, and repository:
// schemas.dart
final userSchema = z.object({
'id': z.string().uuid(),
'email': z.string().email(),
'name': z.string(),
'createdAt': z.dateTime(),
});
// models.dart
extension type User(Map<String, dynamic> _) {
String get id => _['id'] as String;
String get email => _['email'] as String;
String get name => _['name'] as String;
DateTime get createdAt => _['createdAt'] as DateTime;
factory User.create({required String email, required String name}) {
final data = {
'id': const Uuid().v4(),
'email': email,
'name': name,
'createdAt': DateTime.now(),
};
return User(userSchema.parse(data)); // throws ZemaException if invalid
}
User withName(String newName) => User({..._, 'name': newName});
Map<String, dynamic> toJson() => _;
}
// repository.dart
class UserRepository {
final Dio _dio;
UserRepository(this._dio);
Future<User> getUser(String id) async {
final response = await _dio.get('/users/$id');
final result = userSchema.safeParse(response.data);
return switch (result) {
ZemaSuccess(:final value) => User(value),
ZemaFailure(:final errors) =>
throw ApiException('Invalid user data from server', errors: errors),
};
}
Future<User> createUser({required String email, required String name}) async {
final user = User.create(email: email, name: name);
final response = await _dio.post('/users', data: user.toJson());
return User(userSchema.parse(response.data));
}
Future<User> updateUser(String id, {String? name}) async {
final current = await getUser(id);
final updated = name != null ? current.withName(name) : current;
final response = await _dio.put('/users/$id', data: updated.toJson());
return User(userSchema.parse(response.data));
}
}
Anti-patterns summary
| Anti-pattern | Problem | Fix |
|---|---|---|
| Validation in getters | Duplicates schema logic | Trust the schema |
| Mutation of underlying map | Silent state change for all holders | Return new instance via spread copy |
| Heavy computation in getters | Unexpected performance cost | Extract to a named method |
| Non-nullable type for optional field | Hides whether value was absent | Use nullable type |
Runtime is checks on extension type | Always tests representation type | Use safeParse() |
Generic names (Data, Item) | No domain meaning | Use specific noun names |
Checklist
Before creating an extension type:
- Is the data already a
Map<String, dynamic>? - Is runtime type checking needed? If yes, consider a class.
- Is pattern matching across variants needed? If yes, consider a sealed class.
- Is Freezed (
copyWith,==, union types) needed? If yes, use a class with Zema for input validation.
When defining an extension type:
- No validation logic in getters (schema handles it).
- Nullable return types for optional fields.
- Computed properties for derived data.
- Factory constructors for creating instances from raw inputs.
- Update methods return new instances (spread copy).
toJson()method if serialization is needed.- Clear, domain-specific name.
Next steps
- What are Extension Types: compile-time erasure and zero-cost abstraction
- Creating Extension Types: field accessors, methods, operators, and factory constructors
- Extension Types vs Classes: choosing the right abstraction