Flutter is a UI toolkit developed by Google. It’s gained immense popularity for its ability to create beautiful and natively compiled applications for mobile, web, and desktop from a single codebase.
While Dart, the language behind Flutter, is powerful, writing data models often involves repetitive and error-prone tasks. A typical model may require:
Defining a constructor and properties
Overriding
toString,==operator, andhashCodeImplementing a
copyWithmethodWriting serialization (
toJson) and deserialization (fromJson) methods
Doing all this by hand can quickly bloat your code and reduce readability.
This is where Freezed comes in. Freezed is a Dart code generator that creates boilerplate for immutable data classes, unions, pattern matching, cloning, and JSON serialization. With Freezed, you can write concise and safe models while the package handles the repetitive parts.
In this tutorial, you will learn how to use Freezed to create immutable data classes, generate JSON serialization, and implement powerful unions for handling multiple states in a type-safe way. By the end, you’ll know how to reduce boilerplate and make your Flutter code cleaner, safer, and easier to maintain.
Table of Contents
Prerequisites
Before starting, you should be comfortable with:
Flutter basics: Be able to create a new Flutter project and run it on an emulator or device.
Dart language fundamentals: Understand how classes, constructors, and methods work.
Command-line tools: Be able to run commands like
flutter pub getorflutter pub run.JSON concepts: Know what JSON is and how it is commonly used for API data exchange.
If you are already comfortable with these topics, you are ready to dive into Freezed.
Why Freezed?
When building Flutter applications, two challenges often arise when working with data models: immutability and serialization. Freezed helps solve both in a clear and automated way.
1. Immutability
In Dart, objects are mutable by default. This means that once you create an object, its fields can be changed anywhere in your code. While convenient, this can lead to unintended side effects, like accidentally modifying a user object in one part of your app and breaking logic elsewhere.
Ensuring immutability manually requires a lot of boilerplate: you have to declare all fields as final, implement copyWith methods to create modified copies, and correctly override == and hashCode to maintain object equality. This can be repetitive and error-prone.
How Freezed helps:
Freezed automatically generates immutable classes. All fields are final, and a copyWith method is provided so you can safely create modified copies without mutating the original object. Also, Freezed handles == and hashCode for you, which make sure that your objects behave correctly when compared or used in collections. This drastically reduces boilerplate while enforcing immutability.
2. Serialization
When interacting with APIs, converting Dart objects to and from JSON is a common task. Without automation, you have to write toJson and fromJson methods for every class, carefully mapping each field. This is repetitive and easy to get wrong, especially when your models change over time.
How Freezed helps:
Freezed integrates with the json_serializable package to automatically generate serialization and deserialization logic. You just annotate your class and run the code generator, and then Freezed creates fully working toJson and fromJson methods for you. This not only saves time but also reduces the chance of errors and keeps your code clean and maintainable.
Without Freezed: A Manual Example
Here’s what a basic User class looks like without Freezed:
class User {
final String name;
final int age;
final String email;
const User({
required this.name,
required this.age,
required this.email,
});
User copyWith({
String? name,
int? age,
String? email,
}) {
return User(
name: name ?? this.name,
age: age ?? this.age,
email: email ?? this.email,
);
}
Map<String, dynamic> toJson() {
return {
'name': name,
'age': age,
'email': email,
};
}
factory User.fromJson(Map<String, dynamic> json) {
return User(
name: json['name'] as String,
age: json['age'] as int,
email: json['email'] as String,
);
}
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is User &&
runtimeType == other.runtimeType &&
name == other.name &&
age == other.age &&
email == other.email;
@override
int get hashCode => name.hashCode ^ age.hashCode ^ email.hashCode;
@override
String toString() {
return 'User{name: $name, age: $age, email: $email}';
}
}
This is verbose, and it’s easy to miss details like updating hashCode when adding new fields.
With Freezed: A Cleaner Alternative
Now that you understand the challenges Freezed solves, let's see how it makes working with data models simpler and cleaner. In this section, you’ll install the necessary packages, set up a Freezed class, and generate boilerplate code. Once that setup is complete, we’ll dive into examples showing how to use the Freezed class, including copying objects and JSON serialization.
First, install Freezed and its related packages. Add this to your pubspec.yaml file:
dependencies:
freezed_annotation: ^2.4.1
json_annotation: ^4.8.1
dev_dependencies:
flutter_lints: ^2.0.0
build_runner: ^2.0.0
freezed: ^2.4.7
json_serializable: ^6.7.1
Then run:
flutter pub get
For pure Dart projects, use:
dart pub get
Defining a Freezed Class
Create a file named user.dart and add the following:
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user.freezed.dart';
@freezed
class User with _$User {
factory User({required String name, required int age}) = _User;
}
Here’s what’s going on in this code:
import 'package:freezed_annotation/freezed_annotation.dart';: Imports annotations required by Freezed.part 'user.freezed.dart';: Indicates that Freezed will generate code in this file.@freezed: Tells Freezed to process the following class.class User with _$User: Declares theUserclass. Thewith _$Userpart connects the class to generated code.factory User({required String name, required int age}) = _User;: Defines a factory constructor. Freezed generates the implementation class (_User) behind the scenes.
Running Code Generation
Run the following command to generate code:
flutter pub run build_runner watch --delete-conflicting-outputs
For Dart projects:
dart pub run build_runner watch --delete-conflicting-outputs
This creates the user.freezed.dart file, containing boilerplate like copyWith, ==, hashCode, and toString.
Using the Freezed Class
Let’s see Freezed in action:
void main() {
final user = User(name: 'John Doe', age: 25);
final user2 = user.copyWith(name: 'Jane Doe');
final user3 = user2;
print(user);
print(user2);
print(user2 == user3);
print('Name: ${user.name}');
print('Age: ${user.age}');
}
Here’s what’s happening:
final user = User(name: 'John Doe', age: 25);: Creates a new immutableUser.final user2 = user.copyWith(name: 'Jane Doe');: Creates a copy ofuserwith a new name but keeps the same age.final user3 = user2;: Pointsuser3to the same object asuser2.print(user);: Displays a readable string, thanks to the generatedtoString.print(user2 == user3);: Compares objects using generated==.
Adding JSON Serialization
Update user.dart to support JSON:
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user.freezed.dart';
part 'user.g.dart';
@freezed
class User with _$User {
factory User({required String name, required int age}) = _User;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}
In the new parts of the code:
part 'user.g.dart';: Adds another generated file for JSON support.factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);: Enables deserialization from JSON.
Next, run the generator again:
flutter pub run build_runner build --delete-conflicting-outputs
Using JSON Serialization
Example usage:
void main() {
final userJson = {'name': 'Alice', 'age': 30};
final user = User.fromJson(userJson);
print('Name: ${user.name}');
print('Age: ${user.age}');
final userBackToJson = user.toJson();
print('Back to JSON: $userBackToJson');
}
In this code:
final user = User.fromJson(userJson);: Converts a JSON map into aUserinstance.user.toJson();: Converts aUserobject back into JSON.
Advanced Usage: Freezed Unions
So far, we have used Freezed for immutable data models. Another powerful feature of Freezed is unions (also known as sealed classes).
Unions allow you to represent multiple possible states of an object in a type-safe way. This is especially useful in Flutter when working with asynchronous tasks such as API calls, where you often have states like loading, success, and error.
Defining a Union
Create a new file called result.dart:
import 'package:freezed_annotation/freezed_annotation.dart';
part 'result.freezed.dart';
@freezed
class Result<T> with _$Result<T> {
const factory Result.loading() = Loading<T>;
const factory Result.success(T data) = Success<T>;
const factory Result.error(String message) = Error<T>;
}
Line-by-line code explanation:
import 'package:freezed_annotation/freezed_annotation.dart';: Imports the annotation library needed for Freezed.part 'result.freezed.dart';: Tells Freezed to generate boilerplate into this file.@freezed: Instructs Freezed to generate code for the annotated class.class Result<T> with _$Result<T>: Declares a generic classResultthat can hold data of typeT.const factory Result.loading() = Loading<T>;: Defines theloadingstate.Loading<T>is the generated class.const factory Result.success(T data) = Success<T>;: Defines thesuccessstate with associated data.const factory Result.error(String message) = Error<T>;: Defines theerrorstate with a message.
After saving, generate the code:
flutter pub run build_runner build --delete-conflicting-outputs
Using the Union
Let’s simulate an API call and return results using our Result union:
Future<Result<String>> fetchUserData() async {
await Future.delayed(const Duration(seconds: 2)); // simulate network delay
final success = true; // change to false to simulate error
if (success) {
return const Result.success("User data fetched successfully");
} else {
return const Result.error("Failed to fetch user data");
}
}
Here’s what’s going on:
Future<Result<String>> fetchUserData(): Returns aResultobject that containsStringdata.await Future.delayed(...): Simulates a 2-second delay, mimicking a real network call.if (success) { ... } else { ... }: Randomly returns either asuccessorerrorresult.
Pattern Matching with Freezed
One of the best parts of Freezed is pattern matching. You can handle all states without writing long if checks.
void main() async {
final result = await fetchUserData();
result.when(
loading: () => print("Loading..."),
success: (data) => print("Success: $data"),
error: (message) => print("Error: $message"),
);
}
Here’s what’s going on in this code:
result.when(...): Calls the appropriate callback depending on the state.If it’s
loading, it executes theloadingfunction.If it’s
success, it executes thesuccessfunction with the data.If it’s
error, it executes theerrorfunction with the message.
This ensures all states are handled. If you forget one, the compiler will show an error.
MaybeWhen: Handling Partial States
maybeWhen is a safer and more flexible version of when. While when requires you to handle all possible states, maybeWhen lets you handle only the ones you care about and provide a fallback with orElse.
This makes it useful when you’re not interested in every state, but just a subset.
Sometimes you only care about certain states. Here’s how you can use maybeWhen:
result.maybeWhen(
success: (data) => print("Data received: $data"),
orElse: () => print("No data"),
);
Here’s what’s happening:
success: (data)runs only if the current state issuccess.orElseacts as a fallback for all other states (loading,error, etc.).
So in this snippet, the code is showing how you can react only to the success state while safely ignoring everything else.
Map: Working with State Objects Directly
Another approach is map, which provides the full class instance:
result.map(
loading: (value) => print("Currently loading"),
success: (value) => print("Got success: ${value.data}"),
error: (value) => print("Got error: ${value.message}"),
);
Here, each branch receives the generated class (Loading, Success, Error), giving you access to all fields.
Why Use Unions?
Unions shine when building Flutter apps with asynchronous logic. For example:
Network requests:
loading,success,errorForm validation:
valid,invalid,submittingAuthentication:
authenticated,unauthenticated,loading
Instead of writing bool isLoading and String? error flags scattered across your app, unions give you a structured, type-safe way to model state.
Conclusion
Freezed is an essential tool for Flutter developers who want to reduce boilerplate while maintaining safe, immutable, and easily serializable models.
By handling repetitive code such as copyWith, equality checks, and JSON serialization, Freezed lets you focus on building applications instead of writing boilerplate.
Whether you are a beginner or an experienced Flutter developer, Freezed can improve the readability, safety, and maintainability of your codebase.
For advanced features and best practices, visit the official Freezed documentation on pub.dev.