| |

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

PackageThe dependency type
freezed_annotationThe runtime
json_annotationThe runtime
build_runnerThe dev
freezedThe dev
json_serializableThe dev

The Annotations

AnnotationThe purpose
@freezedGenerates 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 methodThe source
copyWithThe freezed
== / hashCodeThe freezed
toStringThe freezed
toJsonThe json_serializable (or the freezed with the fromJson)
fromJsonThe json_serializable

The Build Commands

The commandThe purpose
dart run build_runner buildThe one-time generation
dart run build_runner watchThe continuous generation
--delete-conflicting-outputsRemoves the stale outputs
dart run build_runner cleanRemoves the generated files

The Build Configuration

The optionThe purpose
explicit_to_json: trueThe nested objects
field_rename: snakeThe snake_case keys
copy_with: trueThe freezed copyWith
generic_argument_factories: trueThe 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 pitfallThe whyThe fix
The generated file is missingThe build not rundart run build_runner build
The part directive is missingThe connectionAdd the part
The _$UserFromJson is not foundThe build not runRun the generator
The stale generated codeThe model changedRe-run the generator
The copyWith is missingThe freezed not runRun the generator
The nested list is not serializedThe explicitToJsonAdd the option
The build is slowThe large projectUse the watch mode
The conflict errorThe stale outputsUse 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

ItemValue
json_serializableGenerates the fromJson and toJson
json_annotationThe runtime annotations
@JsonSerializableThe class annotation
@JsonKeyThe field annotation
freezedGenerates the immutable class
freezed_annotationThe runtime annotations
@freezedThe class annotation
copyWithThe generated clone method
build_runnerThe generator runner
partThe generated file connection

Key takeaways:

  • The json_serializable generates the fromJson and the toJson from the @JsonSerializable annotation. The developer writes the class and the annotations, and the generator produces the mapping. The JsonKey customizes the individual fields .
  • The freezed generates the immutable data classes. The @freezed annotation with the factory constructor produces the copyWith, the ==, the hashCode, and the toString. The @Default provides the default values .
  • The two packages 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 .
  • The build_runner runs the generators. The build runs once, and the watch runs continuously. The --delete-conflicting-outputs removes the stale outputs .
  • The explicitToJson handles the nested objects. The option ensures the nested objects are serialized with the toJson, not the toString. The build.yaml configures the option for the whole project .
  • The union types and the pattern matching are the Dart 3 feature. The sealed class with the @freezed generates the union, and the switch expression handles the exhaustive cases .
  • The generated files are the implementation detail. The developer does not edit the .g.dart and the .freezed.dart files. The build_runner clean removes them .
  • The dependencies are the runtime and the dev. The json_annotation and the freezed_annotation are the runtime, and the json_serializable, the freezed, and the build_runner are 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!