Zema Logo
Extension Types

Extension Types vs Classes

Choose between extension types and classes: runtime types, pattern matching, immutability, serialization, inheritance, and when to use each.

Quick comparison

FeatureExtension typeClass
Type safetyCompile-timeCompile-time + runtime
Memory overheadNone (wraps existing value)Object header + fields
Allocation costNoneOne per instance
Runtime typeRepresentation typeDistinct type
is checksCheck representation typeCheck the class itself
Pattern matchingLimitedFull support
Sealed classesNoYes
Inheritanceimplements interfaces onlyFull extends / implements
Freezed / codegenNot compatibleFull support
IDE autocompleteYesYes
Refactoring safetyYesYes

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

ScenarioExtension typeClass
Wrapping API or JSON dataIdealWorks, extra allocation
High-volume data processingZero extra costGC pressure
Domain models with business rulesAwkwardNatural
Sealed unions and pattern matchingNot supportedFull support
Freezed integrationNot compatibleFull support
Guaranteed immutabilityManual (Map.unmodifiable)final fields
No build_runner dependencyYesDepends 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

Copyright © 2026