Zema Logo
Extension Types

Creating Extension Types

Define extension types for Zema-validated maps: getters, optional fields, computed properties, nested types, methods, operators, and real-world examples.

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

Copyright © 2026