Creating Extension Types
Basic structure
An extension type wraps Map<String, dynamic> and exposes typed getters:
extension type User(Map<String, dynamic> _) {
int get id => _['id'] as int;
String get email => _['email'] as String;
String get name => _['name'] as String;
}
The representation field _ holds the underlying map. Getters cast map values to the expected types: the schema has already validated them, so the casts will not fail at runtime.
Step-by-step
1. Define the schema
final userSchema = z.object({
'id': z.integer(),
'email': z.string().email(),
'name': z.string(),
'age': z.integer().optional(),
'createdAt': z.dateTime(),
});
2. Create the extension type
Mirror the schema fields as typed getters. Optional fields become nullable:
extension type User(Map<String, dynamic> _) {
int get id => _['id'] as int;
String get email => _['email'] as String;
String get name => _['name'] as String;
int? get age => _['age'] as int?;
DateTime get createdAt => _['createdAt'] as DateTime;
}
3. Validate and wrap
safeParse() returns ZemaResult<Map<String, dynamic>>. Pass the map to the extension type constructor:
final result = userSchema.safeParse(jsonData);
switch (result) {
case ZemaSuccess(:final value):
final user = User(value);
print(user.email); // String
print(user.age); // int?
print(user.createdAt); // DateTime
case ZemaFailure(:final errors):
for (final issue in errors) {
print('${issue.pathString}: ${issue.message}');
}
}
Or with z.objectAs() to produce ZemaResult<User> directly:
final userSchema = z.objectAs(
{
'id': z.integer(),
'email': z.string().email(),
'name': z.string(),
},
User.new,
);
final result = userSchema.safeParse(jsonData);
// result is ZemaResult<User>
Field accessors
Required fields
After Zema validation, the value is guaranteed to be the expected type. Explicit casts document the intent and catch schema/accessor mismatches immediately:
extension type User(Map<String, dynamic> _) {
String get email => _['email'] as String;
int get age => _['age'] as int;
}
Optional fields
Fields validated with .optional() may be null:
extension type User(Map<String, dynamic> _) {
String? get bio => _['bio'] as String?;
String? get website => _['website'] as String?;
// Optional with fallback
String get displayBio => (_['bio'] as String?) ?? 'No bio provided.';
}
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 displayName => '$firstName $lastName';
bool get isAdmin => ((_['roles'] as List?)?.contains('admin')) ?? false;
}
Nested objects
Wrap nested maps in their own extension type:
extension type Profile(Map<String, dynamic> _) {
String get avatar => _['avatar'] as String;
String get bio => _['bio'] as String;
}
extension type User(Map<String, dynamic> _) {
String get email => _['email'] as String;
Profile get profile => Profile(_['profile'] as Map<String, dynamic>);
}
Arrays
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);
}
Type transformations in getters
When the schema stores a value in a primitive form (string, int), getters can convert it to a richer type. If the schema already produces the target type (e.g. z.dateTime() produces DateTime), no conversion is needed. Just cast.
String to DateTime (schema stores as string)
// Schema: 'createdAt': z.string()
extension type Post(Map<String, dynamic> _) {
DateTime get createdAt => DateTime.parse(_['createdAt'] as String);
}
DateTime directly (schema handles parsing)
// Schema: 'createdAt': z.dateTime() (already a DateTime)
extension type Post(Map<String, dynamic> _) {
DateTime get createdAt => _['createdAt'] as DateTime;
}
String to enum
enum UserRole { admin, user, guest }
// Schema: 'role': z.string().oneOf(['admin', 'user', 'guest'])
extension type User(Map<String, dynamic> _) {
UserRole get role => UserRole.values.byName(_['role'] as String);
}
Nested map to custom class
class Money {
final double amount;
final String currency;
Money(this.amount, this.currency);
}
// Schema: 'price': z.object({'amount': z.double(), 'currency': z.string()})
extension type Product(Map<String, dynamic> _) {
Money get price {
final m = _['price'] as Map<String, dynamic>;
return Money(m['amount'] as double, m['currency'] as String);
}
}
Accessing the underlying map
Declare the representation field with a public name to expose the map:
extension type User(Map<String, dynamic> data) {
String get email => data['email'] as String;
// expose map for serialization
Map<String, dynamic> toJson() => data;
Map<String, dynamic> copy() => Map.from(data);
}
final user = User({'email': 'alice@example.com'});
final json = jsonEncode(user.toJson());
Methods
Extension types can have methods that return new instances (the map is not mutable through normal practice):
extension type User(Map<String, dynamic> _) {
String get name => _['name'] as String;
String get email => _['email'] as String;
String greet() => 'Hello, $name!';
bool hasRole(String role) =>
((_['roles'] as List?)?.contains(role)) ?? false;
// return a new instance with the field changed
User withName(String newName) => User({..._, 'name': newName});
}
final user = User({'name': 'Alice', 'email': 'alice@example.com', 'roles': ['admin']});
final renamed = user.withName('Bob');
print(user.greet()); // 'Hello, Alice!'
print(user.hasRole('admin')); // true
print(renamed.name); // 'Bob'
Operators
Equality
extension type User(Map<String, dynamic> _) {
int get id => _['id'] as int;
String get email => _['email'] as String;
@override
bool operator ==(Object other) => other is User && other.id == id;
@override
int get hashCode => id.hashCode;
}
final a = User({'id': 1, 'email': 'a@example.com'});
final b = User({'id': 1, 'email': 'b@example.com'});
print(a == b); // true (same id)
Ordering
extension type User(Map<String, dynamic> _) implements Comparable<User> {
String get name => _['name'] as String;
@override
int compareTo(User other) => name.compareTo(other.name);
}
final users = [userC, userA, userB];
users.sort(); // sorted by name
Factory constructors
Factories can validate data on construction using parse(), which throws ZemaException on failure:
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
}
}
final user = User.create(email: 'alice@example.com', name: 'Alice');
Multiple views over the same map
Two extension types can wrap the same underlying map to expose different subsets of fields:
extension type User(Map<String, dynamic> data) {
int get id => data['id'] as int;
String get email => data['email'] as String;
String get password => data['password'] as String;
}
extension type PublicUser(Map<String, dynamic> data) {
int get id => data['id'] as int;
String get email => data['email'] as String;
// no password accessor
}
final user = User(raw);
final publicView = PublicUser(user.data); // same map, restricted view
Real-world example: blog post
final postSchema = z.object({
'id': z.string().uuid(),
'title': z.string(),
'content': z.string(),
'authorId': z.string().uuid(),
'tags': z.array(z.string()),
'published': z.boolean(),
'publishedAt': z.dateTime().optional(),
'createdAt': z.dateTime(),
'updatedAt': z.dateTime(),
});
extension type Post(Map<String, dynamic> _) {
String get id => _['id'] as String;
String get title => _['title'] as String;
String get content => _['content'] as String;
String get authorId => _['authorId'] as String;
List<String> get tags => List<String>.from(_['tags'] as List);
bool get published => _['published'] as bool;
DateTime? get publishedAt => _['publishedAt'] as DateTime?;
DateTime get createdAt => _['createdAt'] as DateTime;
DateTime get updatedAt => _['updatedAt'] as DateTime;
// Computed
String get excerpt => content.length > 200
? '${content.substring(0, 200)}...'
: content;
bool get isDraft => !published;
// Return new instance with changes
Post publish() => Post({
..._,
'published': true,
'publishedAt': DateTime.now(),
});
Post addTag(String tag) => Post({
..._,
'tags': [...tags, tag],
});
}
Practices
Keep getters simple: they access already-validated data.
// fine: simple cast
String get email => _['email'] as String;
// fine: nullable optional
String? get bio => _['bio'] as String?;
// fine: transform to richer type
DateTime get createdAt => DateTime.parse(_['createdAt'] as String);
// fine: computed from other getters
String get displayName => '$firstName $lastName';
Do not repeat validation that the schema already performs, do not mutate the underlying map, and do not perform expensive computation in getters. Use a separate method instead.
Next steps
- What are Extension Types: representation types, zero-cost abstraction, and how they work
- Objects:
z.object()andz.objectAs()schemas
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 vs Classes
Choose between extension types and classes: runtime types, pattern matching, immutability, serialization, inheritance, and when to use each.