Understanding JSON → Dart
fromJson, toJson, and a build runner.
Why Dart needs codegen for JSON at all, the two-step build runner workflow, and how Flutter projects keep their models maintainable.
Dart has no runtime reflection.
Dart (especially the AOT-compiled Flutter variant) has no general runtime reflection. There's no equivalent of Java's Class.forName or Python's __dict__ — you can't ask a class what fields it has at runtime. That makes the "annotate and parse" approach used by Jackson or Codable impossible. The alternatives are: write fromJson / toJson methods by hand, or use a build-time code generator. For anything beyond a handful of fields, codegen wins.
json_serializable is the standard answer.
The de-facto pattern: annotate your class with @JsonSerializable(), declare its fields, then run dart run build_runner build and a sibling my_class.g.dart file appears with the generated fromJson / toJson functions. The class itself stays thin; the generated code does the heavy lifting. Re-run the build runner whenever the class changes.
A worked example.
From { "user_id": 7, "name": "Q", "avatar": null }: import 'package:json_annotation/json_annotation.dart';
part 'user.g.dart';
@JsonSerializable()
class User {
@JsonKey(name: 'user_id') final int userId;
final String name;
final String? avatar;
User({required this.userId, required this.name, this.avatar});
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
Map<String, dynamic> toJson() => _$UserToJson(this);
} The _$UserFromJson and _$UserToJson live in the generated .g.dart file. The class declares the shape; codegen does the parsing.
One class + one part file
@JsonSerializable() + part 'X.g.dart'
The .g.dart file is generated; commit it or rebuild on demand.
dart run build_runner build
= Round-trippable User class
Required and nullable.
Dart 2.12 added null safety. A property typed String cannot be null; one typed String? can. Constructor parameters marked required must be passed; ones with default values or nullable types can be omitted. The codegen tool's job: map non-null JSON fields to non-null Dart types, null-or-missing JSON fields to nullable types with optional constructor params.
Freezed for immutable models.
The freezed package builds on json_serializable to add immutability, copyWith, deep equality, and union-type support. For Flutter projects with non-trivial domain models, freezed is the standard upgrade — same codegen workflow, much richer generated API. The trade-off is one more code generator in the pipeline; the benefit is several hundred lines of hand-written boilerplate eliminated.
The build runner cost.
Code generation feels like magic until the build runner takes 30 seconds on a large project. dart run build_runner watch rebuilds incrementally on file changes, which keeps the loop tight. The generated files should be committed (so fresh checkouts don't need a regen before running) but treated as build artifacts — never hand-edited. Some projects gitignore the .g.dart files and regen on every CI run; both styles are valid.
Discriminated unions.
For an array of events with different shapes, freezed provides the cleanest pattern. @freezed sealed class Event with _$Event { const factory Event.click(int x, int y) = Click; const factory Event.submit(String form) = Submit; } The sealed class plus factory constructors gives exhaustive pattern matching via Dart 3's switch expressions. Codegen tools that detect a JSON discriminator should emit this shape; without it, you get a flat class with every field optional, which is the same anti-pattern as everywhere else.