Flutter Freezed Code Generation: A Practical Tutorial
Learn Freezed for Flutter: immutable data classes, unions, and JSON with build_runner. Step-by-step setup, examples, and troubleshooting.
Image used for representation purposes only.
Overview
Freezed is a powerful Dart code generator that makes immutable data classes and sealed/union types effortless. In Flutter apps, it eliminates repetitive boilerplate—equality, copyWith, JSON serialization, and exhaustive pattern matching—so you can focus on behavior and UI.
This tutorial walks you through end‑to‑end setup, core patterns, advanced tips (nested models, generics, unions), and troubleshooting. By the end, you’ll be able to model robust app state and domain data with concise, type‑safe code.
Prerequisites
- Flutter SDK installed
- Basic familiarity with Dart classes and Flutter widgets
- A new or existing Flutter project
Install and Configure Freezed
- Add the required packages
# Using Flutter
flutter pub add freezed_annotation json_annotation
flutter pub add --dev build_runner freezed json_serializable
# Or with Dart directly
dart pub add freezed_annotation json_annotation
dart pub add --dev build_runner freezed json_serializable
- Verify your pubspec.yaml
dependencies:
flutter:
sdk: flutter
freezed_annotation: ^2.0.0
json_annotation: ^4.9.0
dev_dependencies:
build_runner: ^2.4.0
freezed: ^2.0.0
json_serializable: ^6.8.0
Tip: Versions here are illustrative. Allow your package manager to resolve compatible versions for your toolchain.
How Freezed Fits Into Your App Architecture
- Immutability enforces predictable state flows, ideal for Riverpod, Bloc, and other state managers.
- Structural equality removes the need to implement == and hashCode.
- copyWith enables concise, safe updates to immutable objects.
- Unions/sealed types (sum types) model distinct states (loading, success, error) with exhaustive handling.
- JSON integration keeps API models reliable and maintainable.
Your First Model: Immutable Data Class
Create lib/models/user.dart:
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,
@Default(0) int age,
@JsonKey(name: 'is_active') bool? isActive,
DateTime? createdAt,
}) = _User;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}
Now run code generation:
# One-time build
flutter pub run build_runner build --delete-conflicting-outputs
# Or keep it running in watch mode during development
dart run build_runner watch --delete-conflicting-outputs
What you get:
- user.freezed.dart: equality, copyWith, toString, pattern-matching helpers
- user.g.dart: JSON serialization (via json_serializable)
Using Your Model
final user = User(id: 'u1', name: 'Ava', isActive: true);
// copyWith for safe updates
final older = user.copyWith(age: user.age + 1);
// Equality by value
print(user == user.copyWith()); // true
// JSON roundtrip
final json = user.toJson();
final parsed = User.fromJson(json);
Modeling App State with Union Types
Union types let you express mutually exclusive states in a type‑safe way. Create lib/state/auth_state.dart:
import 'package:freezed_annotation/freezed_annotation.dart';
import '../models/user.dart';
part 'auth_state.freezed.dart';
@freezed
class AuthState with _$AuthState {
const factory AuthState.unauthenticated() = Unauthenticated;
const factory AuthState.authenticating({required double progress}) = Authenticating;
const factory AuthState.authenticated({required User user}) = Authenticated;
const factory AuthState.failure({required String message}) = Failure;
}
Pattern‑match exhaustively with when/map:
void render(AuthState state) {
state.when(
unauthenticated: () => print('Sign in'),
authenticating: (progress) => print('Loading: ${(progress * 100).toStringAsFixed(0)}%'),
authenticated: (user) => print('Welcome, ${user.name}'),
failure: (message) => print('Error: $message'),
);
}
Prefer maybeWhen/maybeMap when you want to handle only a subset:
final greeting = state.maybeWhen(
authenticated: (user) => 'Hi ${user.name}',
orElse: () => 'Hello',
);
Building UI with Freezed and Riverpod (Example)
// provider.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'state/auth_state.dart';
import 'models/user.dart';
final authProvider = StateNotifierProvider<AuthController, AuthState>((ref) {
return AuthController(const AuthState.unauthenticated());
});
class AuthController extends StateNotifier<AuthState> {
AuthController(AuthState state) : super(state);
Future<void> signIn() async {
state = const AuthState.authenticating(progress: 0.0);
await Future<void>.delayed(const Duration(milliseconds: 300));
state = const AuthState.authenticating(progress: 0.6);
await Future<void>.delayed(const Duration(milliseconds: 300));
state = AuthState.authenticated(user: User(id: 'u1', name: 'Ava'));
}
}
// widget.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'provider.dart';
class AuthGate extends ConsumerWidget {
const AuthGate({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(authProvider);
return state.when(
unauthenticated: () => Center(
child: ElevatedButton(
onPressed: () => ref.read(authProvider.notifier).signIn(),
child: const Text('Sign in'),
),
),
authenticating: (p) => Center(child: CircularProgressIndicator(value: p)),
authenticated: (user) => Center(child: Text('Welcome ${user.name}')),
failure: (msg) => Center(child: Text('Error: $msg')),
);
}
}
Nested Models and Collections
Freezed works great with nested, JSON‑serializable models and collections. Example: Order with items.
// order_item.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'order_item.freezed.dart';
part 'order_item.g.dart';
@freezed
class OrderItem with _$OrderItem {
const factory OrderItem({
required String sku,
required int quantity,
required int cents,
}) = _OrderItem;
factory OrderItem.fromJson(Map<String, dynamic> json) => _$OrderItemFromJson(json);
}
// order.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'order_item.dart';
part 'order.freezed.dart';
part 'order.g.dart';
@freezed
class Order with _$Order {
const factory Order({
required String id,
@Default(<OrderItem>[]) List<OrderItem> items,
}) = _Order;
factory Order.fromJson(Map<String, dynamic> json) => _$OrderFromJson(json);
}
Collections are immutable by default (unmodifiable views). Use copyWith to add items:
final order = Order(id: 'o1');
final updated = order.copyWith(items: [...order.items, OrderItem(sku: 'A', quantity: 1, cents: 2999)]);
Generics and JSON
For generic models, enable generic factories so json_serializable knows how to (de)serialize T.
import 'package:freezed_annotation/freezed_annotation.dart';
part 'api_response.freezed.dart';
part 'api_response.g.dart';
@Freezed(genericArgumentFactories: true)
class ApiResponse<T> with _$ApiResponse<T> {
const factory ApiResponse({
required T data,
int? code,
String? message,
}) = _ApiResponse<T>;
factory ApiResponse.fromJson(
Map<String, dynamic> json,
T Function(Object? json) fromJsonT,
) => _$ApiResponseFromJson(json, fromJsonT);
}
Usage:
final userResp = ApiResponse<User>.fromJson(
json,
(obj) => User.fromJson(obj as Map<String, dynamic>),
);
Customizing JSON Keys and Defaults
- Use @JsonKey to map snake_case keys and control nullability/ignored fields.
- Prefer @Default for safe defaults instead of nullable fields when meaningful.
@freezed
class Profile with _$Profile {
const factory Profile({
@JsonKey(name: 'display_name') required String displayName,
@Default(true) bool marketingOptIn,
}) = _Profile;
factory Profile.fromJson(Map<String, dynamic> json) => _$ProfileFromJson(json);
}
Testing Freezed Models
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/models/user.dart';
void main() {
test('copyWith updates only selected fields', () {
final u = User(id: '1', name: 'Ava', age: 20);
final v = u.copyWith(age: 21);
expect(v.name, 'Ava');
expect(v.age, 21);
});
}
Performance Notes
- Freezed models are lightweight and compile to plain Dart classes.
- Prefer primitive/DTO fields; avoid storing heavy UI objects inside models.
- Use watch builds (build_runner watch) for faster local iterations.
Common Pitfalls and Fixes
- Missing part files
- Ensure part ‘your_file.freezed.dart’; and, if using JSON, part ‘your_file.g.dart’; are present.
- Generator not producing code
- Re‑run with –delete-conflicting-outputs; close editors that may have stale analysis sessions.
- fromJson not found
- Add json_serializable to dev_dependencies, include the ‘g.dart’ part, and define factory YourType.fromJson.
- Name collisions
- Keep model filenames unique and avoid exporting multiple libraries with the same part names.
- Union JSON shape
- By default, unions encode with a runtimeType discriminator. If you need a custom key or values, configure Freezed options (e.g., unionKey/unionValueCase) and align your backend accordingly.
- Lints about constructors
- Freezed generates const constructors where possible; if your fields prevent const, that’s expected. Avoid storing non‑const objects as defaults.
Workflow Tips
- Keep build_runner watch running in a split terminal tab for near‑instant feedback.
- Separate API DTOs from domain models if you need mapping layers.
- Co‑locate models with features (feature‑first folders) for modularity at scale.
- Prefer exhaustive when/map in reducers and widgets to catch missing states at compile time.
When to Reach for Unions vs. Single Models
- Use a single data class when representing a single shape of data (e.g., User).
- Use unions/sealed types for state machines and workflows (loading/success/error), navigation states, or parse results (success/failure).
Minimal “Checklist” Before Committing
- All model files have matching part directives
- build_runner executed without errors
- Unit tests for critical models’ equality/copyWith/JSON
- Exhaustive when/map handled in reducers/widgets
Troubleshooting Build Runner in CI
- Cache pub packages between runs to speed up.
- Use flutter pub run build_runner build –delete-conflicting-outputs in CI to avoid merge artifacts.
- Consider a pre‑commit hook to verify generation is up to date.
Conclusion
Freezed streamlines your Flutter codebase by automating the repetitive parts of modeling: immutability, equality, copyWith, JSON, and powerful unions for state. With a small amount of setup, you get safer, more maintainable code and a smoother development workflow.
Adopt it incrementally—start with one model or one feature’s state—and let Freezed replace boilerplate across your app.
Related Posts
Flutter CI CD with GitHub Actions: A Step-by-Step Tutorial
Step-by-step Flutter CI CD with GitHub Actions: lint, test, build Android iOS, sign, cache, and release to stores. Ready-to-copy YAML included.
AI Voice Assistant API Integration in Flutter: A Practical End-to-End Guide
Step-by-step Flutter guide to integrate an AI voice assistant API with STT, TTS, streaming patterns, security, and UX best practices.
Flutter Charts: A Practical Data Visualization Guide
A practical, end-to-end Flutter charts guide: choose libraries, build beautiful charts, optimize performance, add interactivity, accessibility, and tests.