| |

Dart 68 🎯 Immutability Architecture: Value Objects, Equatable, and Freezed Union Types

⏱️ Estimated read: 12 min | |

Immutability in Dart is not a language default—it is an architectural choice enforced by discipline and tooling. A mutable object can change at any time, which means every reference to it is a potential surprise. An immutable object, once created, never changes; a “modification” produces a new object, and the old one remains valid for any code still holding it. This property eliminates entire classes of bugs: no accidental shared-state mutation, no defensive copying, no wondering whether a value you passed to a function will be the same when it returns.

Two packages dominate the Dart ecosystem for immutability. Equatable provides value-based equality without code generation—a class extends it, overrides props, and gets ==, hashCode, and optionally toString for free . Freezed provides code generation for immutable classes, copyWith, toString, and union types that replace the need for manual sealed-class boilerplate . They solve different problems and are often used together: Equatable for simple value objects, Freezed for complex models and state unions.

Key point: Equatable gives value equality by comparing a list of properties you define in props; Freezed generates immutable classes with copyWith, toString, and union types from factory constructors, and since version 3.0 requires sealed or abstract class modifiers.


Why immutability architecture matters

The identity problem. Dart’s default == compares object identity, not value. Person("Bob") == Person("Bob") is false because they are different instances . For value objects—money, dates, addresses, coordinates—identity is the wrong comparison. Two Address objects with the same street and city should be equal. Equatable fixes this by comparing a declared list of properties, so the class author decides what “equal” means .

The mutation problem. A mutable object passed to a function can be changed by that function, and the caller has no way to prevent it. An immutable object cannot be changed, so passing it is safe. This is why Equatable requires all fields to be final: if a field could change, the hashCode could change while the object is in a Set or used as a Map key, breaking the collection .

The boilerplate problem. Writing copyWith by hand for a class with ten fields is error-prone: each field needs a null check, and nested objects need deep copies. Freezed generates copyWith, toString, hashCode, and == from a factory constructor. A single @freezed annotation replaces pages of manual code .

The union type problem. Dart 3 introduced sealed classes and exhaustive pattern matching. A result type that is either Success or Failure is naturally a union. Without tooling, you write the sealed class, the subclasses, and a when method by hand. Freezed generates the union from multiple factory constructors: const factory Result.success(String data) = Success; and const factory Result.error(String message) = Error; produce a sealed class with the variants as subclasses .

The version migration problem. Freezed 3.0 changed the required class modifier. A single-constructor class must be abstract, and a union type must be sealed. The old syntax class Person with _$Person no longer compiles in 3.0+ . This breaking change aligns Freezed with Dart’s native sealed-class semantics and removes the generated .map/.when methods in favor of native pattern matching.


a. Value objects with Equatable

Equatable is an abstract class. A value object extends it and overrides props with the fields that define equality. No code generation, no build_runner step .

import 'package:equatable/equatable.dart';

class Address extends Equatable {
  final String street;
  final String city;

  const Address({required this.street, required this.city});

  @override
  List<Object?> get props => [street, city];
}

With this definition, Address(street: 'Main', city: 'Town') == Address(street: 'Main', city: 'Town') is true. The == and hashCode methods are inherited from Equatable and compare the props list element by element .

The stringify getter is optional. When it returns true, Equatable overrides toString() to print the values in props instead of Instance of 'Address'. This is useful for debugging but adds a small runtime cost. By default, stringify is true in debug mode and false in release mode; a class can override it explicitly .

When a class already has a superclass and cannot extend Equatable, use EquatableMixin instead. The mixin provides the same == and hashCode behavior without requiring Equatable as the parent .

Equatable does not generate copyWith. For a simple value object with two fields, writing copyWith by hand is trivial. For larger classes, Freezed is the better choice.


b. Freezed for immutable models and unions

Freezed is a code generator. It requires freezed_annotation as a dependency, freezed and build_runner as dev dependencies, and a part directive for the generated file. The class is annotated with @freezed and declares factory constructors.

A single-constructor model in Freezed 3.0+ uses abstract class:

import 'package:freezed_annotation/freezed_annotation.dart';

part 'user.freezed.dart';

@freezed
abstract class User with _$User {
  const factory User({
    required String name,
    required int age,
  }) = _User;
}

Running dart run build_runner build generates _$User with copyWith, toString, ==, hashCode, and a User.fromJson/toJson if json_serializable is configured . The copyWith method handles nested objects and nullable fields correctly, which is where hand-written implementations usually break.

A union type uses sealed class and multiple factory constructors. Each constructor is a variant, and the right-hand side names the generated subclass:

@freezed
sealed class Result with _$Result {
  const factory Result.success(String data) = Success;
  const factory Result.error(String message) = Error;
}

Freezed 3.0+ does not generate .when or .map methods. Instead, the sealed class works with Dart’s native pattern matching:

String describe(Result result) => switch (result) {
  Success(:final data) => 'Success: $data',
  Error(:final message) => 'Error: $message',
};

The switch is exhaustive because the class is sealed—the compiler knows all variants and rejects a switch that misses one . This is cleaner than the generated .when callbacks, which required specifying every case and returned a value but did not give the compiler the same exhaustiveness guarantee.

Freezed also supports @unfreezed for mutable variants, but this is rarely used. The default is immutability, and the generated copyWith is the intended mutation mechanism: instead of changing a field, you create a new object with the field changed.


c. Choosing between them and combining them

Equatable is for simple value objects where the only need is equality and toString. It requires no build step, has no generated files, and is trivially readable. A Money class with a currency and amount, a Coordinates class with latitude and longitude, or a DateRange with start and end—these are Equatable candidates.

Freezed is for models with many fields, nested objects, JSON serialization, or union types. The copyWith generation alone justifies it for any class with more than three fields. The union type support is unique: no other package generates sealed-class variants from factory constructors as cleanly .

They can be combined. A Freezed class does not need Equatable—Freezed generates == and hashCode itself. But a value object that extends Equatable can be used as a field inside a Freezed model, and the equality will work correctly because Freezed delegates to the field’s ==. The two packages are complementary, not competing.

The version caveat is important. Freezed 3.0+ requires sealed for unions and abstract for single constructors. Projects migrating from Freezed 2.x must update the class modifiers and remove any .when/.map calls in favor of pattern matching . Freezed 4.0, released in 2026, adds support for primary constructors and immutable collections, but the core API remains the same .


Complete Example Session

The following session builds an Equatable value object and a Freezed union type, then demonstrates equality, copyWith, and pattern matching.

// ============================================
// PART 1: EQUATABLE VALUE OBJECT
// ============================================
import 'package:equatable/equatable.dart';

class Money extends Equatable {
  final String currency;
  final int cents;

  const Money({required this.currency, required this.cents});

  @override
  List<Object?> get props => [currency, cents];

  @override
  bool get stringify => true;
}

// ============================================
// PART 2: EQUALITY CHECK
// ============================================
void main() {
  const a = Money(currency: 'USD', cents: 1000);
  const b = Money(currency: 'USD', cents: 1000);
  const c = Money(currency: 'EUR', cents: 1000);

  print(a == b); // true
  print(a == c); // false
}

// ============================================
// PART 3: FREEZED UNION TYPE
// ============================================
import 'package:freezed_annotation/freezed_annotation.dart';

part 'result.freezed.dart';

@freezed
sealed class PaymentResult with _$PaymentResult {
  const factory PaymentResult.success(String transactionId) = PaymentSuccess;
  const factory PaymentResult.declined(String reason) = PaymentDeclined;
  const factory PaymentResult.error(String message) = PaymentError;
}

// ============================================
// PART 4: PATTERN MATCHING
// ============================================
String describe(PaymentResult result) => switch (result) {
  PaymentSuccess(:final transactionId) => 'Paid: $transactionId',
  PaymentDeclined(:final reason) => 'Declined: $reason',
  PaymentError(:final message) => 'Error: $message',
};

// ============================================
// PART 5: COPYWITH ON FREEZED MODEL
// ============================================
@freezed
abstract class Order with _$Order {
  const factory Order({
    required String id,
    required Money total,
    required PaymentResult status,
  }) = _Order;
}

void updateOrder() {
  const order = Order(
    id: 'A1',
    total: Money(currency: 'USD', cents: 5000),
    status: PaymentSuccess('tx_123'),
  );

  final updated = order.copyWith(status: PaymentDeclined('Insufficient funds'));
  print(updated.status); // PaymentDeclined(reason: Insufficient funds)
}

// ============================================
// PART 6: BUILD COMMAND
// ============================================
// dart run build_runner build --delete-conflicting-outputs

The six parts cover the Equatable value object with stringify, an equality check, the Freezed union type, native pattern matching, copyWith on a model that uses the value object, and the build command. What the session demonstrates is that Equatable handles simple value equality without code generation, while Freezed handles the complex cases—unions, deep copies, and generated boilerplate—where hand-writing the code is error-prone.


Quick Reference

Equatable Setup

StepCode
Dependencyequatable: ^2.0.0
Extendclass X extends Equatable
OverrideList<Object?> get props => [field1, field2]
toStringbool get stringify => true
Mixin (if superclass)with EquatableMixin

Freezed Setup

StepCode
Dependenciesfreezed_annotation
Dev dependenciesfreezed, build_runner
Partpart 'file.freezed.dart'
Annotation@freezed
Single classabstract class X with _$X
Unionsealed class X with _$X
Builddart run build_runner build

Freezed 3.0+ Changes

Feature2.x3.0+
Single classclassabstract class
Unionclasssealed class
Pattern matching.when / .mapNative switch

When to Use Which

Use CaseChoice
Simple value object (2–3 fields)Equatable
Model with copyWith (4+ fields)Freezed
Union / sealed classFreezed
No build step desiredEquatable
JSON serializationFreezed + json_serializable

Best Practices

✅ Do This:

class Money extends Equatable {
  @override List<Object?> get props => [currency, cents]; // ✅ final fields only
}
@freezed sealed class Result with _$Result { ... }         // ✅ sealed for unions
@freezed abstract class User with _$User { ... }            // ✅ abstract for single
switch (result) { Success(:final data) => ... }             // ✅ native pattern

❌ Don’t Do This:

class X extends Equatable { final int a; }                  // ❌ no props override
List<Object?> get props => [mutableField];                  // ❌ non-final field
@freezed class Result with _$Result { ... }                 // ❌ 3.0+ requires sealed/abstract
result.when(success: ..., error: ...);                      // ❌ removed in 3.0+

Common Pitfalls

PitfallWhy It HappensFix
Equatable equality always falseForgot props overrideOverride props with fields
hashCode unstableProps contain mutable fieldUse final fields only
Freezed 3.0 compile errorClass not abstract/sealedAdd modifier
.when not foundFreezed 3.0 removed itUse switch pattern matching
copyWith not generatedBuild runner not rundart run build_runner build
Union not exhaustiveClass not sealedChange to sealed class

Real-World Examples

1. Equatable value object

class Email extends Equatable {
  final String value;
  const Email(this.value);
  @override List<Object?> get props => [value];
}

2. Equatable with stringify

@override bool get stringify => true;

3. Freezed single model

@freezed abstract class User with _$User { const factory User({required String name}) = _User; }

4. Freezed union

@freezed sealed class State with _$State { const factory State.loading() = Loading; }

5. Pattern matching on union

switch (state) { Loading() => ..., Data(:final value) => ... }

6. copyWith on model

user.copyWith(name: 'New');

7. Mixin for existing superclass

class MyDate extends DateTime with EquatableMixin { ... }

8. Build runner command

dart run build_runner build --delete-conflicting-outputs

9. Nullable props in Equatable

List<Object?> get props => [name, age]; // age may be null

10. Freezed with JSON

factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);

Visual

Equatable Equality Flow

┌──────────────────────────────────────────────┐
│  a == b                                      │
│         │                                    │
│         ▼                                    │
│  Equatable's operator==                     │
│         │                                    │
│         ▼                                    │
│  Compare a.props to b.props element-wise     │
│         │                                    │
│    ┌────┴────┐                               │
│    │         │                               │
│  equal     not equal                         │
│    │         │                               │
│    ▼         ▼                               │
│  true      false                             │
└──────────────────────────────────────────────┘

Freezed Union Generation

┌──────────────────────────────────────────────┐
│  @freezed                                    │
│  sealed class Result with _$Result {         │
│    const factory Result.success(String) = Success; │
│    const factory Result.error(String) = Error;     │
│  }                                           │
│         │                                    │
│         ▼                                    │
│  build_runner generates:                     │
│  - Result (sealed)                           │
│  - Success (implements Result)               │
│  - Error (implements Result)                 │
│  - copyWith, toString, ==, hashCode          │
└──────────────────────────────────────────────┘

Freezed 2.x vs 3.0+

┌──────────────────────────────────────────────┐
│  2.x                                         │
│  class Person with _$Person                  │
│  result.when(success: ..., error: ...)       │
│                                              │
│  3.0+                                        │
│  abstract class Person with _$Person         │
│  sealed class Result with _$Result           │
│  switch (result) { Success(:final d) => ... }│
│  (native pattern matching, exhaustive)       │
└──────────────────────────────────────────────┘

Decision Flow

┌──────────────────────────────────────────────┐
│  Need union / sealed class?                  │
│  ├─ YES → Freezed                            │
│  └─ NO → Need copyWith or JSON?              │
│          ├─ YES → Freezed                    │
│          └─ NO → Simple value object?        │
│                  ├─ YES → Equatable          │
│                  └─ NO → Write manual ==     │
└──────────────────────────────────────────────┘

Summary

ItemValue
Equatable purposeValue equality without code gen
Equatable requirementOverride props with final fields
EquatableMixinFor classes with existing superclass
stringifyOptional toString from props
Freezed purposeImmutable models, copyWith, unions
Freezed 3.0+ singleabstract class
Freezed 3.0+ unionsealed class
Freezed pattern matchingNative switch, not .when
Build commanddart run build_runner build

Key takeaways:

  • Equatable is for value equality, not code generation. Extend it, override props, and get == and hashCode without a build step .
  • All Equatable fields must be final. A mutable field breaks hashCode stability and corrupts hash-based collections .
  • Freezed generates copyWith, toString, and unions. It is the right tool when the class has more than a few fields or needs to be a union type .
  • Freezed 3.0+ requires abstract for single classes and sealed for unions. The old class modifier no longer compiles .
  • .when and .map are gone in Freezed 3.0+. Use Dart’s native switch pattern matching, which gives exhaustiveness checking for free .
  • They can be combined. A Freezed model can contain Equatable value objects, and equality works through the composition.
  • The build step is required for Freezed. Equatable has no build step; Freezed requires build_runner.

Remember: Immutability is an architectural decision, and Dart gives you two tools to enforce it. Equatable is the lightweight option: extend it, list the fields that define equality, and get value semantics without code generation. Freezed is the heavier option: annotate a factory constructor, run the build, and get copyWith, toString, ==, and—most importantly—sealed union types that work with native pattern matching. Use Equatable for simple values, Freezed for models and state, and remember that Freezed 3.0 changed the required class modifiers. The goal in both cases is the same: objects that cannot surprise you by changing, and equality that reflects what the object is, not where it lives in memory.


Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!