Dart 68 🎯 Immutability Architecture: Value Objects, Equatable, and Freezed Union Types
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
| Step | Code |
|---|---|
| Dependency | equatable: ^2.0.0 |
| Extend | class X extends Equatable |
| Override | List<Object?> get props => [field1, field2] |
toString | bool get stringify => true |
| Mixin (if superclass) | with EquatableMixin |
Freezed Setup
| Step | Code |
|---|---|
| Dependencies | freezed_annotation |
| Dev dependencies | freezed, build_runner |
| Part | part 'file.freezed.dart' |
| Annotation | @freezed |
| Single class | abstract class X with _$X |
| Union | sealed class X with _$X |
| Build | dart run build_runner build |
Freezed 3.0+ Changes
| Feature | 2.x | 3.0+ |
|---|---|---|
| Single class | class | abstract class |
| Union | class | sealed class |
| Pattern matching | .when / .map | Native switch |
When to Use Which
| Use Case | Choice |
|---|---|
| Simple value object (2–3 fields) | Equatable |
Model with copyWith (4+ fields) | Freezed |
| Union / sealed class | Freezed |
| No build step desired | Equatable |
| JSON serialization | Freezed + 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
| Pitfall | Why It Happens | Fix |
|---|---|---|
Equatable equality always false | Forgot props override | Override props with fields |
hashCode unstable | Props contain mutable field | Use final fields only |
| Freezed 3.0 compile error | Class not abstract/sealed | Add modifier |
.when not found | Freezed 3.0 removed it | Use switch pattern matching |
copyWith not generated | Build runner not run | dart run build_runner build |
| Union not exhaustive | Class not sealed | Change 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
| Item | Value |
|---|---|
Equatable purpose | Value equality without code gen |
Equatable requirement | Override props with final fields |
EquatableMixin | For classes with existing superclass |
stringify | Optional toString from props |
Freezed purpose | Immutable models, copyWith, unions |
Freezed 3.0+ single | abstract class |
Freezed 3.0+ union | sealed class |
Freezed pattern matching | Native switch, not .when |
| Build command | dart run build_runner build |
Key takeaways:
Equatableis for value equality, not code generation. Extend it, overrideprops, and get==andhashCodewithout a build step .- All
Equatablefields must befinal. A mutable field breakshashCodestability and corrupts hash-based collections . FreezedgeneratescopyWith,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
abstractfor single classes andsealedfor unions. The oldclassmodifier no longer compiles . .whenand.mapare gone in Freezed 3.0+. Use Dart’s nativeswitchpattern matching, which gives exhaustiveness checking for free .- They can be combined. A
Freezedmodel can containEquatablevalue objects, and equality works through the composition. - The build step is required for
Freezed.Equatablehas no build step;Freezedrequiresbuild_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.
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!