Dart 48 🎯 Code Generation with build_runner, json_annotation, and freezed
Manual JSON serialization is verbose. For every model class, the developer writes the fromJson factory, the toJson method, and the mapping between the JSON fields and the Dart fields. The mapping is repetitive, and it is easy to get wrong when a field is added or renamed. The json_serializable package generates the mapping from the annotations, and the build_runner tool runs the generation. The result is the *.g.dart file with the _$PersonFromJson and the _$PersonToJson functions, and the developer writes only the class and the annotations.
The freezed package goes further. It generates the immutable data classes themselves, including the copyWith, the ==, the hashCode, and the toString. The developer writes the abstract class with the @freezed annotation and the factory constructor, and the package generates the implementation. The combination with the json_serializable generates the serialization for the same classes, and the developer writes the model once. The build_runner runs both generators in the single pass .
The setup is the four dependencies and the two annotations. The freezed_annotation and the json_annotation are the runtime dependencies, and the freezed and the json_serializable are the dev dependencies. The build_runner is the dev dependency that runs the generators. The part directive connects the source file to the generated files, and the build_runner build or the build_runner watch produces them .
This chapter covers three areas. First, why the code generation exists — the boilerplate problem and the generated-code approach. Second, the json_serializable setup — the json_annotation, the @JsonSerializable, the fromJson and the toJson, and the build_runner. Third, the freezed combination — the @freezed, the factory constructor, the copyWith, the pattern matching, and the json_serializable integration. The chapter ends with a complete example session, a quick reference, best practices, common pitfalls, real-world examples, and diagrams showing the generation flow.
Key point: The json_serializable package generates the fromJson and the toJson from the @JsonSerializable annotation. The freezed package generates the immutable data classes with the copyWith, the ==, the hashCode, and the toString from the @freezed annotation. The build_runner runs the generators, and the output is the *.g.dart and the *.freezed.dart files. The part directive connects the source to the generated files. The combination eliminates the boilerplate and enforces the immutability.
Why the code generation exists
The boilerplate problem. The JSON serialization requires the fromJson and the toJson for every model. The fromJson maps the Map<String, dynamic> to the typed fields, and the toJson does the reverse. The mapping is the repetitive, and the repetition is the source of the bugs. The field is added to the class, and the fromJson is not updated. The field is renamed, and the JSON key is not updated. The code generation solves the problem by deriving the mapping from the class definition .
The immutability problem. The Dart classes are mutable by default, and the immutable classes require the manual final fields, the copyWith, the ==, the hashCode, and the toString. The manual implementation is the boilerplate, and the freezed package generates it. The immutable classes are the safer, and the value equality is the automatic .
The pattern-matching problem. The union types and the sealed classes are the Dart 3 feature for the exhaustive pattern matching. The freezed package generates the union types and the pattern-matching helpers, and the developer uses the switch expression. The when and the map are the legacy helpers, and the Dart 3 pattern matching is the modern .
The build-system problem. The code generation is a build step. The build_runner is the standard tool, and it watches the files and regenerates the outputs on the change. The build_runner build runs the generation once, and the build_runner watch runs it continuously. The --delete-conflicting-outputs flag removes the stale outputs .
The dependency problem. The generated code depends on the annotations, and the annotations are the separate packages. The json_annotation is the runtime dependency for the json_serializable, and the freezed_annotation is the runtime dependency for the freezed. The generators themselves are the dev dependencies, and they are not included in the production build .
The trade-off. The code generation adds the build step and the generated files. The generated files are the large, and the build is the slow on the big projects. The generated files are the hidden implementation, and the debugging requires the generated code. The trade-off is between the boilerplate and the build step, and the modern Dart projects choose the generation for the models .
a. The json_serializable setup
The json_serializable requires the two packages: the json_annotation for the annotations and the json_serializable for the generator.
dependencies:
json_annotation: ^4.9.0
dev_dependencies:
build_runner: ^2.4.0
json_serializable: ^6.8.0
The json_annotation is the runtime dependency, and the json_serializable is the dev dependency. The build_runner runs the generator .
The class is annotated with the @JsonSerializable, and the part directive connects the generated file:
import 'package:json_annotation/json_annotation.dart';
part 'user.g.dart';
@JsonSerializable()
class User {
final String firstName;
final String lastName;
final DateTime? dateOfBirth;
User({
required this.firstName,
required this.lastName,
this.dateOfBirth,
});
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
Map<String, dynamic> toJson() => _$UserToJson(this);
}
The part 'user.g.dart'; declares the generated file. The factory User.fromJson and the toJson call the generated _$UserFromJson and the _$UserToJson. The generator produces the user.g.dart with the mapping .
The generated file:
part of 'user.dart';
User _$UserFromJson(Map<String, dynamic> json) => User(
firstName: json['firstName'] as String,
lastName: json['lastName'] as String,
dateOfBirth: json['dateOfBirth'] == null
? null
: DateTime.parse(json['dateOfBirth'] as String),
);
Map<String, dynamic> _$UserToJson(User instance) => <String, dynamic>{
'firstName': instance.firstName,
'lastName': instance.lastName,
'dateOfBirth': instance.dateOfBirth?.toIso8601String(),
};
The generator handles the String, the DateTime, the nullable, and the nested types. The JsonKey annotation customizes the individual fields:
@JsonSerializable()
class User {
@JsonKey(name: 'first_name')
final String firstName;
@JsonKey(includeToJson: false)
final String internalId;
@JsonKey(defaultValue: 'active')
final String status;
}
The name changes the JSON key, the includeToJson excludes the field, and the defaultValue provides the default .
The build_runner runs the generation:
dart run build_runner build --delete-conflicting-outputs
dart run build_runner watch --delete-conflicting-outputs
The build runs once, and the watch runs continuously. The --delete-conflicting-outputs removes the stale outputs .
b. The freezed setup
The freezed requires the freezed_annotation for the annotations and the freezed for the generator.
dependencies:
freezed_annotation: ^3.0.0
dev_dependencies:
build_runner: ^2.4.0
freezed: ^3.0.0
The class is annotated with the @freezed, and the factory constructor defines the fields:
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user.freezed.dart';
@freezed
abstract class User with _$User {
const factory User({
required String firstName,
required String lastName,
required int age,
}) = _User;
}
The abstract class User with _$User declares the class, and the factory User defines the constructor. The _$User is the generated implementation. The generator produces the user.freezed.dart with the copyWith, the ==, the hashCode, and the toString .
The copyWith creates a new instance with the changed fields:
final user = User(firstName: 'John', lastName: 'Doe', age: 25);
final updated = user.copyWith(age: 26);
The == and the hashCode are the value equality:
final user1 = User(firstName: 'John', lastName: 'Doe', age: 25);
final user2 = User(firstName: 'John', lastName: 'Doe', age: 25);
print(user1 == user2); // true
The toString lists the fields:
print(user);
// User(firstName: John, lastName: Doe, age: 25)
The @Default provides the default values:
@freezed
abstract class User with _$User {
const factory User({
required String firstName,
@Default('active') String status,
@Default(0) int age,
}) = _User;
}
The @Default annotation provides the default, and the generated fromJson includes the defaultValue .
c. The freezed and the json_serializable combination
The freezed and the json_serializable work together. The freezed generates the class, and the json_serializable generates the serialization. The part directives connect the source to the two generated files.
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user.freezed.dart';
part 'user.g.dart';
@freezed
abstract class User with _$User {
const factory User({
required String firstName,
required String lastName,
required int age,
}) = _User;
factory User.fromJson(Map<String, Object?> json) => _$UserFromJson(json);
}
The part 'user.freezed.dart'; connects the freezed output, and the part 'user.g.dart'; connects the json_serializable output. The factory User.fromJson calls the generated _$UserFromJson, and the freezed generates the toJson because the fromJson is present .
The generated user.freezed.dart includes the toJson:
Map<String, dynamic> toJson() => _$UserToJson(this);
The freezed detects the json_serializable and generates the toJson automatically when the fromJson is present. The developer does not write the toJson .
The explicitToJson: true handles the nested lists:
@freezed
@JsonSerializable(explicitToJson: true)
abstract class User with _$User {
const factory User({
required String name,
required List<Address> addresses,
}) = _User;
}
The explicitToJson ensures the nested objects are serialized with their toJson method, not the toString .
The build.yaml configures the generators:
targets:
$default:
builders:
json_serializable:
options:
explicit_to_json: true
field_rename: snake
freezed:
options:
copy_with: true
The explicit_to_json handles the nested, the field_rename converts the camelCase to the snake_case, and the copy_with generates the method .
The union types and the pattern matching:
@freezed
sealed class Result with _$Result {
const factory Result.success(String data) = Success;
const factory Result.failure(String error) = Failure;
}
The sealed class with the @freezed generates the union. The Dart 3 pattern matching uses the switch expression:
final message = switch (result) {
Success(:final data) => 'Data: $data',
Failure(:final error) => 'Error: $error',
};
The freezed generates the union types, and the Dart 3 pattern matching handles the exhaustive cases .
Complete Example Session
# ============================================
# PART 1: THE PUBSPEC
# ============================================
dependencies:
freezed_annotation: ^3.0.0
json_annotation: ^4.9.0
dev_dependencies:
build_runner: ^2.4.0
freezed: ^3.0.0
json_serializable: ^6.8.0
# ============================================
# PART 2: THE MODEL
# ============================================
# lib/models/user.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user.freezed.dart';
part 'user.g.dart';
@freezed
abstract class User with _$User {
const factory User({
required int id,
required String name,
required String email,
@Default(true) bool active,
DateTime? createdAt,
}) = _User;
factory User.fromJson(Map<String, Object?> json) => _$UserFromJson(json);
}
# ============================================
# PART 3: THE GENERATED FILES
# ============================================
# lib/models/user.freezed.dart (generated)
# lib/models/user.g.dart (generated)
# The freezed file contains:
# - the copyWith method
# - the == and hashCode
# - the toString
# - the toJson method
# The g.dart file contains:
# - the _$UserFromJson function
# - the _$UserToJson function
# ============================================
# PART 4: THE BUILD RUNNER
# ============================================
# dart run build_runner build --delete-conflicting-outputs
# dart run build_runner watch --delete-conflicting-outputs
# ============================================
# PART 5: THE USAGE
# ============================================
final user = User(
id: 1,
name: 'Alice',
email: 'alice@example.com',
);
// The copyWith
final updated = user.copyWith(name: 'Alice Smith');
// The equality
final user2 = User(
id: 1,
name: 'Alice',
email: 'alice@example.com',
);
print(user == user2); // true
// The JSON
final json = user.toJson();
final decoded = User.fromJson(json);
// The toString
print(user);
// User(id: 1, name: Alice, email: alice@example.com, active: true, createdAt: null)
# ============================================
# PART 6: THE NESTED OBJECT
# ============================================
# lib/models/post.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'user.dart';
part 'post.freezed.dart';
part 'post.g.dart';
@freezed
abstract class Post with _$Post {
const factory Post({
required int id,
required String title,
required User author,
@Default([]) List<String> tags,
}) = _Post;
factory Post.fromJson(Map<String, Object?> json) => _$PostFromJson(json);
}
# ============================================
# PART 7: THE UNION TYPE
# ============================================
# lib/models/result.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'result.freezed.dart';
@freezed
sealed class Result with _$Result {
const factory Result.success(String data) = Success;
const factory Result.failure(String error) = Failure;
}
# ============================================
# PART 8: THE PATTERN MATCHING
# ============================================
final result = Result.success('Data loaded');
final message = switch (result) {
Success(:final data) => 'Data: $data',
Failure(:final error) => 'Error: $error',
};
# ============================================
# PART 9: THE BUILD.YAML
# ============================================
targets:
$default:
builders:
json_serializable:
options:
explicit_to_json: true
field_rename: snake
freezed:
options:
copy_with: true
# ============================================
# PART 10: THE COMPLETE WORKFLOW
# ============================================
# 1. Add the dependencies
# flutter pub add freezed_annotation json_annotation
# flutter pub add --dev build_runner freezed json_serializable
# 2. Write the model with @freezed and @JsonSerializable
# 3. Run the generator
# dart run build_runner build --delete-conflicting-outputs
# 4. Use the generated class
# final user = User.fromJson(json);
# final updated = user.copyWith(name: 'New');
# final json = user.toJson();
The ten parts show the pubspec, the model, the generated files, the build runner, the usage, the nested object, the union type, the pattern matching, the build.yaml, and the complete workflow.
Quick Reference
The Dependencies
| Package | The dependency type |
|---|---|
freezed_annotation | The runtime |
json_annotation | The runtime |
build_runner | The dev |
freezed | The dev |
json_serializable | The dev |
The Annotations
| Annotation | The purpose |
|---|---|
@freezed | Generates the immutable class |
@JsonSerializable() | Generates the fromJson and toJson |
@JsonKey(name: '...') | Customizes the JSON key |
@JsonKey(includeToJson: false) | Excludes from the toJson |
@JsonKey(defaultValue: ...) | Provides the default |
@Default(...) | The freezed default |
@JsonEnum() | The enum serialization |
The Generated Methods
| The method | The source |
|---|---|
copyWith | The freezed |
== / hashCode | The freezed |
toString | The freezed |
toJson | The json_serializable (or the freezed with the fromJson) |
fromJson | The json_serializable |
The Build Commands
| The command | The purpose |
|---|---|
dart run build_runner build | The one-time generation |
dart run build_runner watch | The continuous generation |
--delete-conflicting-outputs | Removes the stale outputs |
dart run build_runner clean | Removes the generated files |
The Build Configuration
| The option | The purpose |
|---|---|
explicit_to_json: true | The nested objects |
field_rename: snake | The snake_case keys |
copy_with: true | The freezed copyWith |
generic_argument_factories: true | The generic types |
Best Practices
✅ Do This:
// Use the @freezed with the @JsonSerializable
@freezed
abstract class User with _$User {
const factory User({required String name}) = _User;
factory User.fromJson(Map<String, Object?> json) => _$UserFromJson(json);
} // ✅
// Use the @Default for the defaults
@Default(true) bool active, // ✅
// Use the @JsonKey for the custom keys
@JsonKey(name: 'first_name') String firstName, // ✅
// Use the build_runner watch during the development
// dart run build_runner watch -d // ✅
// Use the --delete-conflicting-outputs
// dart run build_runner build --delete-conflicting-outputs // ✅
// Use the explicitToJson for the nested
@JsonSerializable(explicitToJson: true) // ✅
❌ Don’t Do This:
// Don't forget the part directives
// part 'user.freezed.dart'; part 'user.g.dart'; // ❌
// Don't edit the generated files
// The .g.dart and the .freezed.dart are the generated // ❌
// Don't forget the build_runner after the model change
// The generated code is stale // ❌
// Don't use the @freezed without the _$User mixin
abstract class User with _$User { ... } // the missing with // ❌
// Don't forget the factory keyword
const User({required String name}) = _User; // the factory // ❌
// Don't use the freezed for the mutable classes
@freezed // the immutable; use @unfreezed for the mutable // ❌
// Don't forget the json_annotation in the dependencies
// The JsonSerializable is not found // ❌
Common Pitfalls
| The pitfall | The why | The fix |
|---|---|---|
| The generated file is missing | The build not run | dart run build_runner build |
| The part directive is missing | The connection | Add the part |
The _$UserFromJson is not found | The build not run | Run the generator |
| The stale generated code | The model changed | Re-run the generator |
The copyWith is missing | The freezed not run | Run the generator |
| The nested list is not serialized | The explicitToJson | Add the option |
| The build is slow | The large project | Use the watch mode |
| The conflict error | The stale outputs | Use the --delete-conflicting-outputs |
Real-World Examples
1. The Basic Model
@freezed
abstract class User with _$User {
const factory User({required String name}) = _User;
factory User.fromJson(Map<String, Object?> json) => _$UserFromJson(json);
}
2. The Default
@Default(true) bool active,
3. The Custom Key
@JsonKey(name: 'first_name') String firstName,
4. The Nested
@JsonSerializable(explicitToJson: true)
5. The Union
@freezed
sealed class Result with _$Result {
const factory Result.success(String data) = Success;
const factory Result.failure(String error) = Failure;
}
6. The Pattern Matching
final message = switch (result) {
Success(:final data) => 'Data: $data',
Failure(:final error) => 'Error: $error',
};
7. The Build Command
dart run build_runner build --delete-conflicting-outputs
8. The Watch
dart run build_runner watch --delete-conflicting-outputs
9. The Build.yaml
targets:
$default:
builders:
json_serializable:
options:
explicit_to_json: true
10. The Complete
final user = User.fromJson(json);
final updated = user.copyWith(name: 'New');
final json = user.toJson();
Visual
The Code Generation Flow
┌──────────────────────────────────────────────────────────────┐
│ THE CODE GENERATION FLOW │
│ │
│ The source: │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ lib/models/user.dart │ │
│ │ │ │
│ │ import 'package:freezed_annotation/...'; │ │
│ │ part 'user.freezed.dart'; │ │
│ │ part 'user.g.dart'; │ │
│ │ │ │
│ │ @freezed │ │
│ │ abstract class User with _$User { │ │
│ │ const factory User({ │ │
│ │ required String name, │ │
│ │ }) = _User; │ │
│ │ factory User.fromJson(...) => _$UserFromJson(...);│ │
│ │ } │ │
│ └──────────────────────────────────────────────────────┘ │
│ │ │
│ │ dart run build_runner build │
│ ▼ │
│ The generated: │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ lib/models/user.freezed.dart (the freezed) │ │
│ │ - the copyWith │ │
│ │ - the == / hashCode │ │
│ │ - the toString │ │
│ │ - the toJson │ │
│ │ │ │
│ │ lib/models/user.g.dart (the json_serializable) │ │
│ │ - the _$UserFromJson │ │
│ │ - the _$UserToJson │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘
The Freezed Class
┌──────────────────────────────────────────────────────────────┐
│ THE FREEZED CLASS │
│ │
│ @freezed │
│ abstract class User with _$User { │
│ const factory User({ │
│ required String name, │
│ required int age, │
│ }) = _User; │
│ } │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ The generated: │ │
│ │ │ │
│ │ User copyWith({String? name, int? age}) │ │
│ │ bool operator ==(Object other) │ │
│ │ int get hashCode │ │
│ │ String toString() │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ The usage: │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ final user = User(name: 'Alice', age: 30); │ │
│ │ final updated = user.copyWith(age: 31); │ │
│ │ print(user == updated); // false │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘
The Union Type
┌──────────────────────────────────────────────────────────────┐
│ THE UNION TYPE │
│ │
│ @freezed │
│ sealed class Result with _$Result { │
│ const factory Result.success(String data) = Success; │
│ const factory Result.failure(String error) = Failure; │
│ } │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ The pattern matching: │ │
│ │ │ │
│ │ final message = switch (result) { │ │
│ │ Success(:final data) => 'Data: $data', │ │
│ │ Failure(:final error) => 'Error: $error', │ │
│ │ }; │ │
│ │ │ │
│ │ The switch is exhaustive because the class is │ │
│ │ sealed and the cases are the only two. │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘
The Build Runner
┌──────────────────────────────────────────────────────────────┐
│ THE BUILD RUNNER │
│ │
│ The build: │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ dart run build_runner build │ │
│ │ → the one-time generation │ │
│ │ │ │
│ │ dart run build_runner watch │ │
│ │ → the continuous generation │ │
│ │ → the files regenerate on the change │ │
│ │ │ │
│ │ --delete-conflicting-outputs │ │
│ │ → the stale outputs are removed │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
│ The generated files: │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ *.freezed.dart ← the freezed output │ │
│ │ *.g.dart ← the json_serializable output │ │
│ │ │ │
│ │ The developer does not edit these files. │ │
│ └──────────────────────────────────────────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
json_serializable | Generates the fromJson and toJson |
json_annotation | The runtime annotations |
@JsonSerializable | The class annotation |
@JsonKey | The field annotation |
freezed | Generates the immutable class |
freezed_annotation | The runtime annotations |
@freezed | The class annotation |
copyWith | The generated clone method |
build_runner | The generator runner |
part | The generated file connection |
Key takeaways:
- The
json_serializablegenerates thefromJsonand thetoJsonfrom the@JsonSerializableannotation. The developer writes the class and the annotations, and the generator produces the mapping. TheJsonKeycustomizes the individual fields . - The
freezedgenerates the immutable data classes. The@freezedannotation with the factory constructor produces thecopyWith, the==, thehashCode, and thetoString. The@Defaultprovides the default values . - The two packages work together. The
freezedgenerates the class, and thejson_serializablegenerates the serialization. Thepartdirectives connect the source to the two generated files . - The
build_runnerruns the generators. Thebuildruns once, and thewatchruns continuously. The--delete-conflicting-outputsremoves the stale outputs . - The
explicitToJsonhandles the nested objects. The option ensures the nested objects are serialized with thetoJson, not thetoString. Thebuild.yamlconfigures the option for the whole project . - The union types and the pattern matching are the Dart 3 feature. The
sealedclass with the@freezedgenerates the union, and theswitchexpression handles the exhaustive cases . - The generated files are the implementation detail. The developer does not edit the
.g.dartand the.freezed.dartfiles. Thebuild_runner cleanremoves them . - The dependencies are the runtime and the dev. The
json_annotationand thefreezed_annotationare the runtime, and thejson_serializable, thefreezed, and thebuild_runnerare the dev .
Remember: The json_serializable and the freezed are the code generation tools for the Dart models. The json_serializable generates the JSON mapping, and the freezed generates the immutable classes. The build_runner runs the generators, and the part directives connect the source to the generated files. The result is the models with the copyWith, the equality, the toString, and the JSON serialization, without the boilerplate. The generated files are the implementation detail, and the source is the single place for the model definition.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!