Code Generation & build_runner
build_runner, part files, json_serializable, freezed, build.yaml, source_gen, build times
- 01
Your model declares
part 'user.g.dart';and the generated factory is_$UserFromJson— why a part file instead of a plain import?EasyBecause the generated code is private to your library and reaches into your class: a part shares the library's scope, so
_$UserFromJsoncan start with an underscore and still be visible fromuser.dart.// lib/models/user.dart — the library import 'package:json_annotation/json_annotation.dart'; import 'address.dart'; // the part file cannot import this itself part 'user.g.dart'; // ✅ you write this; the generator never adds it … - 02
Half the team types
build_runner build --delete-conflicting-outputsby reflex — what conflict is that flag actually deleting?EasyA build writes a file only if build_runner already owns it, and
--delete-conflicting-outputs(-d) is you granting permission to take over generated files that exist on disk but are missing from its asset graph.# ── the day-to-day loop: incremental, stays running ──────────── dart run build_runner watch --delete-conflicting-outputs # ── after a pull, a branch switch or a dependency upgrade ────── dart run build_runner build --delete-conflicting-outputs # -d for short … - 03
A reviewer complains that your twelve-file PR is four thousand lines because of
.g.dart— should generated code be in git at all?EasyFor an application, no — ignore it and generate it in CI; for a package you publish to pub.dev, yes, because
dart pub publishbuilds its archive from tracked files and your consumers do not run your builders.# ── App repo: generated code is a build artefact ────────────── $ cat .gitignore .dart_tool/ *.g.dart *.freezed.dart *.mocks.dart … - 04
Calling
toJson()on an order puts anAddressobject inside the map instead of a nested map — which json_serializable option did you forget?MediumexplicitToJson: true.@JsonSerializable(explicitToJson: true, fieldRename: FieldRename.snake) class Order { const Order({ required this.id, required this.address, required this.items, … - 05
The API sends timestamps as epoch millis and money as integer minor units — how do you teach json_serializable about a type it cannot decode?
MediumWrite a
JsonConverter<Dart, Wire>and hang it on the field or the class; the generator then calls yourfromJson/toJsoninstead of emitting a cast.class Money { const Money(this.minorUnits, this.currency); final int minorUnits; final String currency; } … - 06
One payload arrives with
biomissing,nameexplicitly null andageas the string "30" — what does the generatedfromJsondo with each?MediumIt casts and nothing else, so a missing key and an explicit null are indistinguishable, and both
nameandagethrow aTypeErrorthat does not say which key failed.@JsonSerializable(checked: true) class Profile { const Profile({required this.name, required this.age, this.bio = ''}); final String name; … - 07
A reviewer asks why a ten-field
@freezedmodel produced three hundred lines inuser.freezed.dart— what is all of that, and what does it cost?MediumEverything you would otherwise hand-write for a value type: a mixin carrying
==,hashCode,toStringand the entry point forcopyWith, the concrete class the factory redirects to, and a copyWith helper class per model.// lib/models/user.dart import 'package:freezed_annotation/freezed_annotation.dart'; part 'user.freezed.dart'; // copyWith, ==, hashCode, toString part 'user.g.dart'; // fromJson/toJson, from json_serializable … - 08
Dart 3 gives you sealed classes, exhaustive switches and destructuring for free — what is left for a freezed union in 2026?
MediumOnly the members the language still will not write for you: value equality, a per-variant
copyWithand JSON with a discriminator — the matching itself is identical now, because freezed 3 droppedwhen/mapin favour of pattern matching.// ── Hand-written: no build step, exhaustive by construction ─────── sealed class LoadState { const LoadState(); } final class Idle extends LoadState { const Idle(); } … - 09
Only ten files in your app are models, but every build_runner run touches all of lib/ — what goes in build.yaml to narrow that?
MediumScope each builder with
generate_forinside a target, so its inputs are a glob of the files that actually carry annotations instead of the package default oflib/**.# build.yaml — next to pubspec.yaml, at the package root targets: $default: builders: # short key works when package and builder share a name; # the full form is json_serializable:json_serializable … - 10
A full build_runner run on your app now takes four minutes. Where is that time actually going, and which fix wins the most back?
MediumAlmost all of it is the analyzer resolving Dart libraries on behalf of builders, so the wins come from letting fewer files reach fewer builders and from never throwing the asset graph away.
# Cold: no asset graph, every input hashed, every applied builder resolved. $ dart run build_runner build --delete-conflicting-outputs [INFO] Succeeded after 3m41s with 412 outputs # Same command again — .dart_tool/build survived, so only changed inputs run. $ dart run build_runner build … - 11
A new hire clones the repo, opens the IDE and sees 200 analyzer errors in files nobody has touched. What is wrong, and how do you stop it happening?
MediumNothing is wrong with the code — the
.g.dartand.freezed.dartparts are gitignored and have not been generated yet, so everypartdirective dangles and everything that depends on a generated symbol fails with it.#!/usr/bin/env bash # tool/bootstrap.sh — the one command in the README, and the same one CI runs. set -euo pipefail dart pub get … - 12
Your team wants a one-annotation generator for analytics event classes. What do you actually have to write and register for build_runner to run it?
MediumThree pieces: a
Generator(in practiceGeneratorForAnnotation<T>), a top-level factory function that wraps it in aSharedPartBuilderorLibraryBuilder, and abuilders:entry inbuild.yamlnaming that function.// package:my_gen/builder.dart import 'package:analyzer/dart/element/element.dart'; import 'package:build/build.dart'; import 'package:source_gen/source_gen.dart'; import 'package:my_annotations/my_annotations.dart'; // class AnalyticsEvent … - 13
In a melos monorepo, feature_cart has annotated models but nothing is generated for it, while the app package is fine. What is wrong with the graph?
Hardfeature_cartalmost certainly has no dev_dependency on the generator package, and even once it does, build_runner writes generated sources only into the package it is run in — so the fix is an edge per package plus a build per package.# packages/core_annotations/pubspec.yaml — pure Dart, ships in the app name: core_annotations environment: {sdk: ^3.9.0} # no analyzer, no build, no source_gen here --- … - 14
Dart macros were supposed to make build_runner unnecessary and then got cancelled. What are you actually building on today, and what do augmentations change?
Hardbuild_runner and source_gen, unchanged — macros were dropped in early 2025 without ever shipping, and the piece of that work that survived is augmentations, which changes what generated code looks like, not how it is produced.
// ── Today: the mixin-and-free-function dance ───────────────────────────── // user.dart import 'package:json_annotation/json_annotation.dart'; part 'user.g.dart'; @JsonSerializable() … - 15
You add a string to app_en.arb, run build_runner, and AppLocalizations still has no getter for it. Why not?
HardBecause localization is not part of the build_runner pipeline at all —
AppLocalizationsis written byflutter gen-l10n, a subcommand of the Flutter tool driven byl10n.yaml, and build_runner has never heard of your ARB files.# l10n.yaml — read by `flutter gen-l10n`, NOT by build_runner arb-dir: lib/l10n/arb template-arb-file: app_en.arb output-localization-file: app_localizations.dart output-class: AppLocalizations output-dir: lib/l10n # a real directory, not package:flutter_gen … - 16
Generated files are committed and half the PRs carry a stale .g.dart. How do you make CI catch that without asking reviewers to read generated diffs?
HardRegenerate in CI and fail on any change to the working tree, then mark the generated files
linguist-generatedso nobody has to look at them in the pull request.# .github/workflows/codegen.yaml name: codegen on: pull_request jobs: drift: …