Mastering Freezed in Flutter: How to Write Immutable, Type-Safe, and Scalable Code Like a Pro

Mastering Freezed in Flutter: How to Write Immutable, Type-Safe, and Scalable Code Like a Pro

FlutterPulse

This 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 matching
  • user.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:

  • AuthLoading
  • Authenticated (with a User)
  • Unauthenticated
  • AuthError (with a String message)

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 != null or state 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 Profile elsewhere too (e.g., for admins, guests, etc.).
  • 🧪 Type safety throughout your tree: user.profile.age is always there — no nulls unless you define them.
  • 🔧 Easier testing and mocking: You can create isolated instances of Profile and User for 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.dart and part files
  • ❌ Not using --delete-conflicting-outputs
  • ❌ Using Freezed for tiny one-off DTOs (sometimes plain class is enough)
  • ❌ Over-nesting union types for every state (use responsibly)

🚀 Performance & Build Tips

Is Freezed "heavy"? Not really.

Tips:

  • ✅ Use build_runner watch for 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?

🧩 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!

Report Page