Code Generation: freezed, json_serializable & build_runner
Automate your model layer — immutable classes, JSON serialization, and unions with zero boilerplate
Open interactive version (quiz + challenge)Real-world analogy
What is it?
Code generation in Dart uses build_runner to read annotations in your source code and produce generated .g.dart and .freezed.dart files. freezed creates immutable model classes with copyWith, equality, and unions. json_serializable creates JSON fromJson/toJson methods. Together, they eliminate thousands of lines of boilerplate while guaranteeing consistency.
Real-world relevance
In a production Flutter app with 50+ API models, code generation saves weeks of development time. Every model gets guaranteed deep equality (critical for BLoC/Riverpod state comparison), immutable copyWith (for state updates), and type-safe JSON parsing (no runtime surprises from API changes). The build_runner pipeline runs in CI to validate that generated code matches source annotations.
Key points
- The build_runner pipeline — how code generation works in Dart — build_runner is Dart's code generation orchestrator. You write annotated source files (.dart), build_runner reads the annotations, invokes generators (freezed, json_serializable, etc.), and outputs .g.dart or .freezed.dart files via `part` directives. Command: `dart run build_runner build` for one-time, `dart run build_runner watch` for continuous. The generated files are checked into version control (debatable but common). The pipeline: source → annotation → generator → .g.dart → compile.
- Part files — the part/part of directive — Generated code uses Dart's `part` directive. In your source: `part 'user.freezed.dart';` and `part 'user.g.dart';`. The generated file starts with `part of 'user.dart';`. This means the generated code shares the same library scope — it can access private members. Never edit generated files — they're overwritten on every build. If you see errors about missing parts, run build_runner. Common interview question: why part instead of import? Answer: part files share the library's private namespace, which generators need to access _ members.
- freezed — immutable models with copyWith and unions — freezed generates: immutable class with const constructor, copyWith with null-aware deep copy, toString, == (deep equality), hashCode, and optional JSON serialization. Declaration: `@freezed class User with _$User { const factory User({required String name, required int age, @Default('') String bio}) = _User; }`. The `= _User` redirected constructor is freezed's signature pattern. copyWith: `user.copyWith(name: 'New')` returns a new instance. Deep equality: `User(name: 'A', age: 1) == User(name: 'A', age: 1)` is true.
- freezed unions (sealed classes before Dart 3) — freezed can model union types: `@freezed class AuthState with _$AuthState { const factory AuthState.authenticated(User user) = Authenticated; const factory AuthState.unauthenticated() = Unauthenticated; const factory AuthState.loading() = AuthLoading; }`. Pattern matching: `state.when(authenticated: (user) => Home(), unauthenticated: () => Login(), loading: () => Spinner())`. With Dart 3 sealed classes, freezed unions are less critical but still provide copyWith, equality, and JSON for free.
- json_serializable — annotations and custom converters — json_serializable generates fromJson/toJson methods. Add `@JsonSerializable()` to a class and `factory User.fromJson(Map json) => _$UserFromJson(json);` plus `Map toJson() => _$UserToJson(this);`. With freezed: add `factory User.fromJson(Map json) => _$UserFromJson(json);` — freezed delegates to json_serializable. Custom converter example: `class DateTimeConverter implements JsonConverter { const DateTimeConverter(); @override DateTime fromJson(String json) => DateTime.parse(json); @override String toJson(DateTime object) => object.toIso8601String(); }`. Apply with `@DateTimeConverter()` on the field.
- @JsonKey — field-level serialization control — @JsonKey customizes per-field JSON behavior. `@JsonKey(name: 'user_name')` maps Dart camelCase to snake_case JSON. `@JsonKey(defaultValue: 0)` provides a default if the JSON field is missing. `@JsonKey(ignore: true)` excludes a field from serialization. `@JsonKey(fromJson: _statusFromJson, toJson: _statusToJson)` uses custom conversion functions. `@JsonKey(unknownEnumValue: Status.unknown)` handles unexpected enum values gracefully instead of throwing. Combine with @Default from freezed for the cleanest API.
- @Default and enum serialization — freezed's @Default sets a default value: `@Default([]) List tags`. For enums, json_serializable converts by name by default. Override with `@JsonEnum(valueField: 'code')` or `@JsonValue('custom_value')` on enum members. Gotcha: if the API sends an int for an enum, you need a custom converter — json_serializable expects String by name. Common pattern: `@JsonKey(unknownEnumValue: JsonKey.nullForUndefinedEnumValue)` returns null for unknown values instead of crashing.
- Performance: build_runner vs manual code — Build time: initial full build can take 30-60 seconds for a large project (200+ models). Incremental (watch mode): 1-3 seconds per file change. Runtime performance: generated code is identical to hand-written — no reflection, no mirrors, pure Dart. The tradeoff is developer time: a 10-field model with copyWith, ==, hashCode, toString, fromJson, toJson is ~120 lines by hand vs ~8 lines with freezed. At 50 models, that's 6,000 lines of boilerplate eliminated. Generated code is also guaranteed consistent — no forgotten hashCode updates.
- Custom build.yaml configuration — build.yaml in project root customizes build_runner behavior. Common settings: `targets: $default: builders: json_serializable: options: explicit_to_json: true` (generates toJson calls on nested objects). `any_map: true` allows Map from Firestore. `field_rename: snake` auto-converts camelCase to snake_case globally. `checked: true` adds validation with helpful error messages. Per-builder options let you configure freezed and json_serializable independently.
- Dart 3 alternatives: records, sealed classes, and macros (preview) — Dart 3 introduced records `(String name, int age)`, sealed classes for union types, and pattern matching. These reduce the need for freezed in some cases. However, freezed still provides: copyWith (no Dart native equivalent), deep equality on classes, JSON integration, and union exhaustiveness with .when(). Dart macros (in preview) may eventually replace code generation entirely — macros run at compile time, no build_runner needed. For now, freezed + json_serializable remains the production standard.
- Organizing generated code in a production project — Structure: `lib/models/user.dart` + `lib/models/user.freezed.dart` + `lib/models/user.g.dart`. Keep models in a dedicated directory. Use barrel files: `lib/models/models.dart` with `export 'user.dart';`. Add generated files to .gitignore (optional, team preference) or commit them (faster CI, no build step needed). In CI: `dart run build_runner build --delete-conflicting-outputs` ensures clean generation. Lint rule: `avoid_relative_lib_imports` keeps generated part files working correctly.
- Interview questions about code generation — Common questions: (1) Why use freezed over hand-written models? Answer: consistency, zero boilerplate, guaranteed ==, copyWith, and union support. (2) What's the runtime cost? Answer: zero — generated code is pure Dart, no reflection. (3) How do you handle breaking API changes? Answer: @JsonKey(name:) for field renames, custom converters for type changes, @Default for new fields. (4) Why part instead of import for generated files? Answer: part files share the library's private namespace. (5) How would you migrate away from freezed? Answer: copy the generated code, remove annotations — the generated code IS the implementation.
Code example
// freezed + json_serializable — production model example
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:json_annotation/json_annotation.dart';
part 'user.freezed.dart';
part 'user.g.dart';
// Custom JSON converter for DateTime
class DateTimeConverter implements JsonConverter<DateTime, String> {
const DateTimeConverter();
@override
DateTime fromJson(String json) => DateTime.parse(json);
@override
String toJson(DateTime date) => date.toIso8601String();
}
// Enum with custom JSON serialization
@JsonEnum(fieldRename: FieldRename.snake)
enum UserRole {
@JsonValue('admin')
admin,
@JsonValue('editor')
editor,
@JsonValue('viewer')
viewer,
}
// freezed model with JSON support
@freezed
class User with _$User {
const factory User({
required int id,
@JsonKey(name: 'full_name') required String name,
required String email,
@Default(UserRole.viewer) UserRole role,
@Default([]) List<String> permissions,
@DateTimeConverter() required DateTime createdAt,
@JsonKey(includeIfNull: false) String? avatarUrl,
}) = _User;
// Private constructor for custom getters
const User._();
// Custom getter on freezed class
bool get isAdmin => role == UserRole.admin;
// JSON factory
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}
// freezed union type — API response
@freezed
class ApiResponse<T> with _$ApiResponse<T> {
const factory ApiResponse.success(T data) = ApiSuccess<T>;
const factory ApiResponse.error(String message, {int? code}) = ApiError<T>;
const factory ApiResponse.loading() = ApiLoading<T>;
}
// Usage in production code
void handleResponse(ApiResponse<User> response) {
// Exhaustive pattern matching
final message = response.when(
success: (user) => 'Welcome, ${user.name}!',
error: (msg, code) => 'Error ${code ?? 0}: $msg',
loading: () => 'Loading...',
);
print(message);
}
// copyWith — immutable updates
User updateUserRole(User user, UserRole newRole) {
return user.copyWith(role: newRole);
// Returns NEW User instance — original unchanged
}
// Deep equality — works out of the box
void equalityDemo() {
final u1 = User(id: 1, name: 'Alice', email: 'a@b.com',
createdAt: DateTime(2024));
final u2 = User(id: 1, name: 'Alice', email: 'a@b.com',
createdAt: DateTime(2024));
print(u1 == u2); // true — deep value equality
print(u1.hashCode == u2.hashCode); // true
}
// JSON round-trip
void jsonDemo() {
final json = {
'id': 1,
'full_name': 'Alice',
'email': 'a@b.com',
'role': 'admin',
'created_at': '2024-01-15T10:30:00Z',
};
final user = User.fromJson(json);
print(user.name); // Alice (mapped from full_name)
print(user.isAdmin); // true
final back = user.toJson();
print(back['full_name']); // Alice
}Line-by-line walkthrough
- 1. DateTimeConverter: custom JsonConverter that transforms between DateTime and ISO 8601 String — used with @DateTimeConverter() annotation
- 2. UserRole enum: @JsonValue maps Dart enum names to exact API string values — 'admin' not 'UserRole.admin'
- 3. User class: @freezed annotation triggers freezed code generation — `with _\$User` mixes in generated methods
- 4. @JsonKey(name: 'full_name'): maps Dart field 'name' to JSON key 'full_name' — API compatibility without renaming Dart code
- 5. @Default(UserRole.viewer): field defaults to viewer if not provided — works in both constructor and JSON deserialization
- 6. const User._(): private constructor allows adding custom getters (isAdmin) to a freezed class
- 7. ApiResponse union: three named constructors define the union variants — success, error, loading
- 8. response.when(): exhaustive pattern match — compiler ensures you handle all three cases
- 9. copyWith returns a NEW instance — original User is never mutated, critical for state management equality checks
- 10. equalityDemo: two User instances with same values are == true — freezed generates deep value equality
- 11. JSON round-trip: fromJson reads 'full_name' into name field, toJson writes it back — bidirectional mapping
Spot the bug
@freezed
class Product with _$Product {
const factory Product({
required int id,
required String name,
required double price,
}) = _Product;
// Custom method
String get displayPrice => '$${price.toStringAsFixed(2)}';
factory Product.fromJson(Map<String, dynamic> json) =>
_$ProductFromJson(json);
}Need a hint?
Show answer
Explain like I'm 5
Fun fact
Hands-on challenge
More resources
- freezed Official Documentation (pub.dev)
- json_serializable Official Guide (pub.dev)
- build_runner Documentation (pub.dev)
- Andrea Bizzotto — Dart & Flutter Data Classes with Freezed (codewithandrea.com)
- Dart 3 Patterns and Sealed Classes (dart.dev)