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¶
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.