Mastering Freezed in Flutter: How to Write Immutable, Type-Safe, and Scalable Code Like a Pro
FlutterPulseThis article was translated specially for the channel FlutterPulseYou'll find lots of interesting things related to Flutter on this channel. Don't hesitate to subscribe!🚀

🧠 Hook: "I thought Freezed was just for data classes…"
🧠 Hook: "I thought Freezed was just for data classes…"
That was me, two projects ago. I'd been using Freezed just to avoid boilerplate in data models — copyWith, equality, hashCode, the usual suspects.
But then I hit a wall in a complex BLoC state management setup. The app had 7+ states, multiple nested models, and a mix of sync and async errors. It was messy.
That's when Freezed went from "just a nice-to-have" to "oh wow, this just saved my entire state flow."
If you've only scratched the surface, let's level up together. 💪
🚨 Why Freezed?
The Problem with Mutable Data
Dart, by default, encourages mutable classes. This can silently introduce bugs in large codebases:
user.name = "New Name"; // Happens somewhere... but where?da
This kind of silent mutation is dangerous, especially when you're dealing with shared state across widgets or layers.
Freezed to the Rescue 🧊
Freezed makes your data immutable by design. You don't get to mutate — you clone with intent:
final updatedUser = user.copyWith(name: "New Name");
Plus, you get:
- 🔄 Auto
copyWith - 🧪 Deep equality & hashCode
- 🔀 Union types & sealed classes
- 🎭 Pattern matching
- 📦 JSON serialization
- 💥 Boilerplate obliteration
⚙️ Setup and Configuration
First, add these dependencies to your pubspec.yaml:
dependencies:
freezed_annotation: ^2.4.1
json_annotation: ^4.8.1
dev_dependencies:
build_runner: ^2.4.6
freezed: ^2.4.6
json_serializable: ^6.7.1
Now create your model:
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user.freezed.dart';
part 'user.g.dart';
@freezed
class User with _$User {
const factory User({
required String id,
required String name,
}) = _User;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}
Run:
flutter pub run build_runner build --delete-conflicting-outputs
You'll get:
user.freezed.dart: immutability, equality, unions, pattern matchinguser.g.dart: JSON serialization
✅ Clean. Type-safe. Zero boilerplate.
🔍 Deep Dive: Features You're Probably Underrating
1. Union/Sealed Classes
At its core, a union or sealed class in Freezed represents a value that can be one of many distinct, pre-defined types — each with its own associated data (or none at all).
Freezed makes this concept super ergonomic in Dart.
@freezed
class AuthState with _$AuthState {
const factory AuthState.loading() = AuthLoading;
const factory AuthState.authenticated(User user) = Authenticated;
const factory AuthState.unauthenticated() = Unauthenticated;
const factory AuthState.error(String message) = AuthError;
}
Here, AuthState can be exactly one of:
AuthLoadingAuthenticated(with aUser)UnauthenticatedAuthError(with aStringmessage)
Perfect for state management and async flows.
2. Pattern Matching
Freezed gives you powerful pattern matching tools that let you respond to each state or variant of a union/sealed class in a clean and readable way.
state.when(
loading: () => showSpinner(),
authenticated: (user) => showHome(user),
unauthenticated: () => showLogin(),
error: (msg) => showError(msg),
);
Or maybeWhen if you want fallback logic.
This replaces messy if/else or switch/case logic that relies on checking runtimeType or boolean flags. Instead, you get:
- 🧠 Compile-time safety: If you add a new state and forget to handle it, the compiler complains.
- 😌 No more null checks: You don't need to manually check if
user != nullorstate is Authenticated. - ✨ Cleaner, declarative UI rendering
3. CopyWith & Deep Immutability
Immutability is great, but updating nested values can be a headache — unless you have copyWith
final updated = user.copyWith(
name: "Updated Name",
profile: user.profile.copyWith(age: 30),
);
This gives you:
- 🔒 Immutable safety: No accidental mutations.
- 🧼 Predictable updates: You know what changed and where.
- ✨ Concise syntax: No need to reassign every field.
And yes — Freezed automatically generates deep copyWith for all nested Freezed models. 🙌
Say goodbye to deeply nested mutation logic.
4. Composition & Nested Models
Let's say you're building a User model, and that user has a Profile model nested inside. With Freezed, you can cleanly define and reuse that nested structure:
@freezed
class Profile with _$Profile {
const factory Profile({
required int age,
required String bio,
}) = _Profile;
factory Profile.fromJson(Map<String, dynamic> json) => _$ProfileFromJson(json);
}
Now compose it in another model:
@freezed
class User with _$User {
const factory User({
required String id,
required String name,
required Profile profile,
}) = _User;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}
🔑 Benefits of this approach:
- 🔄 Reusable building blocks: Use
Profileelsewhere too (e.g., for admins, guests, etc.). - 🧪 Type safety throughout your tree:
user.profile.ageis always there — no nulls unless you define them. - 🔧 Easier testing and mocking: You can create isolated instances of
ProfileandUserfor tests.
And since both support JSON serialization and copyWith, this structure is 100% API- and state-management–ready.
🔗 Freezed + JSON Serialization
If you're hitting APIs, this is your friend:
Nested Models
@freezed
class Post with _$Post {
const factory Post({
required String title,
required User author,
}) = _Post;
factory Post.fromJson(Map<String, dynamic> json) => _$PostFromJson(json);
}
Custom Converters
Need a DateTime from a string?
@JsonKey(fromJson: parseDate, toJson: formatDate)
final DateTime createdAt;
Use Dart functions or create a JsonConverter.
🌍 Real-World Use Cases
✅ State Management (Bloc/Cubit)
Use sealed classes for robust and readable states.
@freezed
class CounterState with _$CounterState {
const factory CounterState.initial() = Initial;
const factory CounterState.loading() = Loading;
const factory CounterState.success(int count) = Success;
const factory CounterState.error(String message) = Error;
}
No more null-checks or confusing switch-cases.
🧱 Clean Architecture Models
Entities → DTOs → View Models — Freezed makes it easy to clone, validate, and serialize at every layer.
❌ Error Handling with Sealed Classes
@freezed
class ApiResult<T> with _$ApiResult<T> {
const factory ApiResult.success(T data) = Success<T>;
const factory ApiResult.failure(String message) = Failure<T>;
}
Say goodbye to null-or-error spaghetti.
⚠️ Pitfalls to Avoid
- ❌ Forgetting
.g.dartandpartfiles - ❌ Not using
--delete-conflicting-outputs - ❌ Using Freezed for tiny one-off DTOs (sometimes plain
classis enough) - ❌ Over-nesting union types for every state (use responsibly)
🚀 Performance & Build Tips
Is Freezed "heavy"? Not really.
Tips:
- ✅ Use
build_runner watchfor faster feedback loops - ✅ Use union types only where you need them
- ✅ Keep files modular and lean
💬 Final Thoughts
If you're building anything serious in Flutter — especially with async logic, complex state, or API layers — Freezed isn't just helpful. It's necessary.
The more your project scales, the more you'll thank yourself for going immutable, sealed, and type-safe from the start.
🧰 Want to go even deeper?
- sealed_unions [https://pub.dev/packages/sealed_unions]
- equatable [https://pub.dev/packages/equatable]
- dartz [https://pub.dev/packages/dartz]
🧩 Bonus: Before/After
Before:
class User {
String name;
int age;
User(this.name, this.age);
bool operator ==(Object other) => ... // 😵
User copyWith({String? name, int? age}) => ... // 😵
}After:
@freezed
class User with _$User {
const factory User({required String name, required int age}) = _User;
}
You just leveled up from "DIY" to "pro."
💬 What about you? Have you used Freezed in creative or unexpected ways? Share your thoughts or lessons in the comments — I'd love to learn from your experience too!