Freezed for Flutter: Immutable Data Classes, Unions, and Deep CopyWithout the Boilerplate
A guide to Freezed v4 for Dart and Flutter: generated copyWith, equality, and toString; sealed union types with pattern matching; @Default, @Assert, JSON serialization, deep copies, and when to prefer it over hand-written classes.
Published on • September 25, 2026
AI Assistant

Write a Dart model by hand and you will write the same five things every time: a constructor, toString, ==, hashCode, and a copyWith that gets it subtly wrong the first time you add a nullable field. Then, if the model crosses a network boundary, fromJson/toJson. Multiply by every model in the app — a mid-sized project carries dozens — and the boilerplate becomes a permanent tax, with the == implementations being the bugs you find in production when two “identical” objects turn out not to be equal.
Freezed, Rémi Rousselet’s code generator (current version 4.0.2, a Flutter Favorite pulling roughly 2.8 million weekly downloads), exists to delete that tax. You describe the model; Freezed generates the rest — immutability, equality, copyWith including the hard nullable cases, toString, tagged unions, and JSON serialization wired into json_serializable.
Setup
flutter pub add dev:build_runner freezed_annotation dev:freezed
# If you need JSON serialization:
flutter pub add json_annotation dev:json_serializable
Then run the generator in watch mode while developing:
dart run build_runner watch -d
Files using Freezed need the annotation import and part declarations:
import 'package:freezed_annotation/freezed_annotation.dart';
part 'person.freezed.dart';
part 'person.g.dart'; // only if serializable
A Basic Model
@freezed
abstract class Person with _$Person {
const factory Person({
required String firstName,
required String lastName,
required int age,
}) = _Person;
factory Person.fromJson(Map<String, Object?> json) => _$PersonFromJson(json);
}
That declaration generates copyWith, toString, ==, hashCode, and (because fromJson exists) toJson. Two conventions matter: the mixin _$Person (the generated superclass), and factory constructors — Freezed models are constructed through generated factories, not a hand-written constructor.
The copyWith that actually works
The classic hand-written copyWith breaks on nullable fields — copyWith(age: null) cannot distinguish “set to null” from “not provided”. Freezed’s generated version uses a sentinel type so both cases work:
const person = Person(firstName: 'Ada', lastName: 'Lovelace', age: 36);
final aged = person.copyWith(age: 37); // change a value
final erased = person.copyWith(age: null); // set to null — correctly
Deep copy
For nested Freezed models, Freezed generates a chainable copy syntax that traverses the tree and rebuilds only the path you touched:
final updated = company.copyWith.director.assistant(name: 'John Smith');
Nullable levels in the chain are handled safely — you copy through optionality without manual null checks at every step.
@freezed vs @unfreezed
Two flavors cover the two halves of the immutability decision:
@freezed— everything immutable. Generated==andhashCode,constconstruction, andList/Map/Setproperties are made unmodifiable (opt out per-class with@Freezed(makeCollectionsUnmodifiable: false)).@unfreezed— mutable properties, no custom==/hashCode, noconst. For models that genuinely need mutation, without giving up the rest of the generated machinery.
In state management, default to @freezed — equality is what makes widgets rebuild correctly and deduplication work.
Unions: Sealed Classes Without the Ceremony
Freezed’s headline feature is the union type. Multiple factories define mutually exclusive cases of one sealed hierarchy:
@freezed
sealed class AsyncValue<T> with _$AsyncValue<T> {
const factory AsyncValue.data(T value) = Data<T>;
const factory AsyncValue.loading() = Loading<T>;
const factory AsyncValue.error([String? message]) = Error<T>;
}
The generated class is a Dart 3 sealed hierarchy, so pattern matching over it is exhaustiveness-checked — miss a case and the code does not compile. The modern idiom is Dart 3 pattern matching (preferred over the legacy generated when/map methods):
String render(AsyncValue<int> value) => switch (value) {
AsyncValueData(:final value) => 'Got $value',
AsyncValueLoading() => 'Loading…',
AsyncValueError(:final message) => 'Error: $message',
};
Only properties shared by every constructor are directly accessible on the union; state-specific fields are reached through pattern matching. Cases can carry @Implements/@With to implement interfaces or mix in behavior per-case, and any case can be “ejected” — hand-written as a subclass — when it needs custom logic.
This is the exact pattern behind loading/data/error states in Bloc, Riverpod, and result types; Freezed makes it five lines instead of five classes.
Defaults, Assertions, and Custom Constructors
@freezed
abstract class Settings with _$Settings {
const factory Settings({
@Default(42) int pageSize,
@Default(true) bool darkMode,
@Assert('pageSize > 0 && pageSize <= 500') int pageSize,
}) = _Settings;
}
@Default(...)supplies default values — with support for non-constant defaults via a private constructor.@Assert(...)adds constructor assertions.- A private constructor (
Settings._()) lets you run custom logic on construction, inject non-constant defaults, or manage inheritance yourself.
JSON Serialization
With fromJson declared, Freezed and json_serializable round-trip the model. For unions, Freezed reads a runtimeType-style discriminator key in the JSON to pick the constructor — customizable per class:
@Freezed(unionKey: 'type', unionValueCase: FreezedUnionCase.snake)
sealed class Notification with _$Notification { ... }
or per-case with @FreezedUnionValue('push_notification'). Generics get @Freezed(genericArgumentFactories: true), which adds the fromJsonT parameter, and awkward JSON shapes can be adapted with custom JsonConverters. Project-wide defaults live in build.yaml.
Freezed and Dart 3.13 Primary Constructors
Dart 3.13 brought native primary constructors, and some of what Freezed generates — concise constructor syntax — is now built into the language. So is Freezed obsolete?
Not quite. Native language features give you shorter declarations; Freezed still generates what the language deliberately does not: structural ==/hashCode, the sentinel-based copyWith with nullable handling, deep-copy chains, union serialization, and the @Default/assertion conveniences. Freezed 4 also supports classic classes — you write the constructor and fields, and Freezed adds only copyWith/toString/==/hashCode around them — which composes cleanly with primary-constructor style. The practical split in 2026: tiny internal value types can stay hand-written or use primary constructors, while anything state-shaped, JSON-shaped, or equality-sensitive still benefits from Freezed.
When to Use Freezed
- State classes for Bloc/Riverpod — immutability plus exhaustive unions plus correct equality is exactly the contract state management wants.
- API models — JSON round-tripping with discriminated unions for polymorphic payloads.
- Value objects — any type whose identity is its data.
- Anywhere you have written
==by hand more than once this month.
Skip it for one-off classes with no equality needs, and remember it is build-time only: freezed_annotation ships with your app but the generator never does.
Summary
Freezed remains one of the highest-leverage packages in the Dart ecosystem. It converts the most error-prone boilerplate in everyday development — equality, copying, unions, serialization — into generated code that handles the edge cases (nullable copyWith, discriminated JSON, exhaustive switches) correctly by construction. Combined with Dart 3 sealed-class pattern matching and Dart 3.13 primary constructors, it lets your models be both shorter and safer than what most teams write by hand.