Skip to content

09 · Code Generation

JSON covered writing fromJson/toJson by hand. That's fine for one class; it gets tedious and error-prone (typo a field name once and you have a silent bug) across dozens of models. Dart's answer is code generation: annotate a class, run a build tool, and let generated Dart code do the repetitive part — kept in sync with your class automatically every time you regenerate it.

# pubspec.yaml
dependencies:
  json_annotation: ^4.9.0
dev_dependencies:
  build_runner: ^2.4.0
  json_serializable: ^6.8.0

Annotating a class

@JsonSerializable() marks a class for generation; part 'user.g.dart'; tells Dart that a file generated alongside this one supplies the rest of the class's implementation.

// lib/user.dart
import 'package:json_annotation/json_annotation.dart';

part 'user.g.dart';

@JsonSerializable()
class User {
  User({required this.name, required this.age});

  final String name;
  final int age;

  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
  Map<String, dynamic> toJson() => _$UserToJson(this);
}

_$UserFromJson and _$UserToJson don't exist anywhere you wrote — they're what the generator is going to produce.

Running the generator

dart run build_runner build

This scans the project for @JsonSerializable() classes and writes a matching user.g.dart next to each annotated file:

// lib/user.g.dart -- GENERATED, do not edit by hand
part of 'user.dart';

User _$UserFromJson(Map<String, dynamic> json) => User(
      name: json['name'] as String,
      age: (json['age'] as num).toInt(),
    );

Map<String, dynamic> _$UserToJson(User instance) => <String, dynamic>{
      'name': instance.name,
      'age': instance.age,
    };

With that file in place, User behaves exactly like a hand-written JSON model:

import 'dart:convert';

void main() {
  final user = User(name: 'Ada', age: 30);
  print(jsonEncode(user.toJson()));

  final decoded = User.fromJson(jsonDecode('{"name":"Grace","age":85}'));
  print('${decoded.name} is ${decoded.age}');
}
// {"name":"Ada","age":30}
// Grace is 85

Notice (json['age'] as num).toInt() rather than a plain as int cast — the generator hedges against JSON numbers arriving as double (which is common from JavaScript-originated APIs), something hand-written parsing code frequently forgets to do.

The trap: forgetting the part declaration

Every generated-code class needs its part 'xxx.g.dart'; line pointing at the file the generator will produce. Leave it out (or typo the filename) and the generated functions genuinely don't exist as far as the analyzer is concerned — even though user.g.dart might already exist on disk from a previous run.

// lib/user.dart -- 'part' line accidentally removed
import 'package:json_annotation/json_annotation.dart';

@JsonSerializable()
class User {
  User({required this.name, required this.age});
  final String name;
  final int age;
  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
  Map<String, dynamic> toJson() => _$UserToJson(this);
}
dart analyze lib/user.dart
  error - The method '_$UserFromJson' isn't defined for the type 'User'.
  error - The method '_$UserToJson' isn't defined for the type 'User'.

The fix is just restoring the part line — but the error message ("method isn't defined") gives no hint that a missing part is the cause, which is what makes this trap take longer to spot than it should the first time.

Regenerating after model changes, and watch mode

Any time you add, remove, or rename a field, the .g.dart file is now stale and must be regenerated — it does not happen automatically on save. --delete-conflicting-outputs clears previously generated files that would otherwise conflict (useful right after a big rename); watch reruns the builder automatically on every file save during active development:

dart run build_runner build --delete-conflicting-outputs
dart run build_runner watch   # regenerate continuously while you edit

Cheat sheet

Concept Meaning
@JsonSerializable() Marks a class for fromJson/toJson generation
part 'x.g.dart'; Required — links the class to its generated implementation file
dart run build_runner build Runs all configured generators once
dart run build_runner watch Regenerates automatically on every save
--delete-conflicting-outputs Clears stale generated files that would otherwise conflict
.g.dart files Generated — never hand-edit; regenerate instead
(json['x'] as num).toInt() Generator's defensive cast for numeric fields from JSON

How It Actually Works

Dart code generation (via build_runner and packages like json_serializable) doesn't run inside the normal Dart VM/AOT compilation pipeline at all — it's a separate, earlier phase. build_runner uses the analyzer package (the same static-analysis engine that powers the IDE's error checking) to parse your annotated source files into an AST without executing them, walks that AST looking for the annotations (@JsonSerializable(), etc.) your generator registers interest in, and then your generator emits new Dart source text as a .g.dart file. That generated file is then compiled normally, right alongside your hand-written code, by the regular Dart toolchain.

This is exactly why the part/part of declaration is not optional boilerplate: part establishes that the generated file shares the same library (the same top-level namespace and private-member visibility) as your original file, which is required because generated code for a fromJson/toJson typically needs to construct your class using its private fields and constructors directly — without part, the generated code would be a separate library with no access to those private members, and code generation for that class simply couldn't work.

Watch mode (build_runner watch) works by having the build system register file-system watchers on your source directories and re-run only the affected generation steps incrementally when a file changes — it maintains a dependency graph between input files and generated outputs so that editing one model doesn't force regenerating every .g.dart file in the project, only the ones whose inputs actually changed.

Exercise

Create an @JsonSerializable() class Product with fields name (String), price (double), and inStock (bool, defaulting to true when absent from JSON using @JsonKey(defaultValue: true)). Run build_runner build to generate its .g.dart file, then write a main() that decodes a JSON string missing the inStock key and prints the resulting Product's inStock value (should be true), then encodes a second Product back to a JSON string and prints it.