Skip to content
Blog

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 == and hashCode, const construction, and List/Map/Set properties are made unmodifiable (opt out per-class with @Freezed(makeCollectionsUnmodifiable: false)).
  • @unfreezed — mutable properties, no custom ==/hashCode, no const. 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.

References