Zema Logo
Extension Types

Extension Type Best Practices

Patterns, anti-patterns, and conventions for defining and using extension types with Zema-validated maps.

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> _) { ... }

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-patternProblemFix
Validation in gettersDuplicates schema logicTrust the schema
Mutation of underlying mapSilent state change for all holdersReturn new instance via spread copy
Heavy computation in gettersUnexpected performance costExtract to a named method
Non-nullable type for optional fieldHides whether value was absentUse nullable type
Runtime is checks on extension typeAlways tests representation typeUse safeParse()
Generic names (Data, Item)No domain meaningUse 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

Copyright © 2026