Metaprogramming in Dart
This is Part 7 — the final content part of the Dart Internals & Performance series (the question bank is next). We end on metaprogramming: code that writes code.
If you've used json_serializable, freezed, riverpod_generator, retrofit, drift, or ffigen (from Part 5), you've already relied on Dart metaprogramming — you just ran dart run build_runner build and got files full of code you didn't write. This part explains how that machinery works, how to reason about it, and the dramatic recent history: Dart spent years building macros to replace it all… then cancelled them in January 2025. Knowing why is genuinely useful for deciding how to structure your code today.
We're on Dart 3.12. The macros decision and the pivot to augmentations + improved
build_runnerare current as of mid-2026.
What metaprogramming is — and why Dart does it statically
Metaprogramming means a program that treats code as data — inspecting it, transforming it, or generating new code. The classic uses are exactly the boring, error-prone boilerplate you hate writing by hand:
fromJson/toJsonserialization==,hashCode,copyWith,toStringfor data classes- Dependency-injection wiring, route tables, API clients
- Type-safe bindings (like
ffigen's C bindings)
There are two broad strategies, and Dart deliberately favors one:
| Strategy | When code is inspected | Dart's stance |
| --- | --- | --- |
| Runtime reflection (dart:mirrors) | While the app runs | ❌ Avoided — breaks AOT/tree-shaking |
| Static code generation (build_runner) | At build time, before compile | ✅ The standard |
Why not reflection? Because of everything in Part 2
Dart has a reflection library, dart:mirrors, but you essentially can't use it in Flutter. Here's the chain of reasoning, straight from earlier parts:
Reflection lets code discover classes/fields at runtime. But AOT compilation relies on tree shaking — removing everything not statically reachable. If any code could reflect over any class at runtime, the compiler can't prove anything is unused, so tree-shaking dies and binaries balloon. So
dart:mirrorsis unsupported in Flutter / AOT.
That single constraint is why the Dart ecosystem standardized on build-time code generation instead: do the "inspect and generate" work before compilation, emit plain Dart source, and let AOT tree-shake normally. Performance (Part 2) shaped the entire metaprogramming culture.
The engine: build_runner + source_gen
build_runner is Dart's build-system orchestrator. It runs builders that read your source files and write new ones, caching aggressively so re-runs are incremental.
source_gen is a layer on top that makes writing generators easy, especially annotation-driven ones. The typical flow:
your annotated .dart ──► build_runner runs builders ──► generated .g.dart / .freezed.dart
(you write this) (reads + analyzes source) (you import, never edit)
You participate through three conventions: an annotation, a part directive, and a command.
1. The annotation marks intent
Generators key off const annotations (Part 4 — annotations must be constant expressions). You annotate a class to say "generate code for this."
import 'package:json_annotation/json_annotation.dart';
part 'user.g.dart'; // ← the generated file, by convention
@JsonSerializable()
class User {
final String name;
final int age;
User(this.name, this.age);
// These delegate to the generated code:
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
Map<String, dynamic> toJson() => _$UserToJson(this);
}
2. The part directive stitches in the output
The generated file is a
partof your library:part 'user.g.dart';. The generator emitsuser.g.dartcontaining_$UserFromJson/_$UserToJson, and because it's a part, those private members are visible to your file. You import the result by writing thepartdirective — never by editing the generated file.
3. The command runs the build
# One-off build:
dart run build_runner build
# Re-run automatically on save (great during development):
dart run build_runner watch
# When the analyzer complains about stale outputs:
dart run build_runner build --delete-conflicting-outputs
After running, user.g.dart exists with the real serialization logic — code you'd have written by hand, generated correctly and kept in sync.
Mental model: code generation is a pre-compile step. It happens before the pipeline in Part 1 even starts — the generated
.dartis just more source the CFE then lowers to kernel like anything else. There's zero runtime cost to the generation itself; it's all done at build time.
The ecosystem you already use
Almost every "magic" Dart/Flutter package is build-time codegen under the hood:
| Package | Generates | Annotation |
| --- | --- | --- |
| json_serializable | fromJson/toJson | @JsonSerializable() |
| freezed | immutable data classes, unions, copyWith, == | @freezed |
| riverpod_generator | providers from functions/classes | @riverpod |
| retrofit | type-safe HTTP clients | @RestApi() |
| drift | SQL database access | @DriftDatabase() |
| ffigen | C bindings (Part 5) | (config-driven) |
| auto_route / go_router_builder | type-safe routing | @RoutePage() |
Spotting the pattern: annotation +
part+build_runner. Once you internalize it, every one of these libraries works the same way — and debugging "why didn't my code generate?" almost always comes down to a missingpartdirective, a missing annotation, or forgetting to run the build.
Writing your own generator (the shape of it)
You rarely need to, but seeing the skeleton demystifies the whole thing. With source_gen, you extend GeneratorForAnnotation<T> and emit a string of Dart source:
// In a separate package (e.g. my_gen) depended on as a dev/build dependency.
import 'package:source_gen/source_gen.dart';
import 'package:analyzer/dart/element/element.dart';
import 'package:build/build.dart';
class ToStringGenerator extends GeneratorForAnnotation<GenerateToString> {
@override
String generateForAnnotatedElement(
Element element, ConstantReader annotation, BuildStep step) {
final cls = element as ClassElement;
final fields = cls.fields.map((f) => "${f.name}: \$${f.name}").join(', ');
// Return Dart SOURCE as a string — build_runner writes it to a .g.dart part:
return '''
extension ${cls.name}ToString on ${cls.name} {
String describe() => '${cls.name}($fields)';
}
''';
}
}
The key insight: a generator reads the analyzed program (via the analyzer package's Element model) and returns Dart source as a String. build_runner handles file I/O, ordering, and caching. That's the entire concept — it's "templating with type information."
The macros story: the future that wasn't
Now the part everyone's heard rumors about. For several years the Dart team built macros — a way to do metaprogramming inside the language, no build_runner, no part files, no separate build step. You'd write something like @JsonCodable() and the macro would synthesize the methods during compilation, with full type information.
The promise was enormous:
- No
dart run build_runner build, no generated files to commit or ignore. - Instant — generation folded into normal compilation.
- Fully type-aware, IDE-friendly.
It was the single most anticipated Dart feature in years. And then, in January 2025, the Dart team announced they were stopping work on macros.
Why macros were cancelled
The reason ties back to the heart of this entire series — Part 2's hot reload:
Macros needed to run during compilation and, critically, re-run during incremental compilation to figure out whether generated code changed. That re-execution slowed the first step of hot reload enough to damage the sub-second developer experience that defines Flutter. The team concluded they couldn't get acceptable performance for a feature requiring deep semantic introspection on every incremental rebuild — so they shelved it indefinitely.
It's a beautifully on-theme lesson: the very thing that makes Flutter feel magical (fast, JIT-powered hot reload — Part 2) was the constraint that killed macros. Performance trade-offs aren't an afterthought in Dart; they shape what features can even exist.
What's happening instead
The team didn't abandon better metaprogramming — they redirected:
-
Augmentations (shipping). A new
augmentkeyword lets you split a declaration across multiple files — add members to a class, fill in a method body, etc., from a separate file. This is the foundation macros would have built on, and on its own it makes generated code cleaner (a generator can augment your class instead of relying onpartglue).// user.dart class User { final String name; User(this.name); } // user.g.dart (generated) — augments the SAME class: augment class User { Map<String, dynamic> toJson() => {'name': name}; } -
Better
build_runner. With macros off the table, the team committed to improving the existing code-generation experience — faster builds, better DX — rather than replacing it. -
The ecosystem adapted. Notably, Freezed 3.0 shipped without its planned macro-based rewrite, staying on
build_runner. The community's takeaway:build_runneris the stable, blessed path for the foreseeable future.
What this means for you in 2026: don't wait for macros — they aren't coming. Learn
build_runner/source_genwell; it's the standard and will be for years. Keep an eye on augmentations, which are quietly making that standard nicer.
Practice Challenges
Challenge 1 — Why not mirrors? A teammate suggests using dart:mirrors to auto-serialize models in a Flutter app. Give the one-sentence reason that won't work.
Show solution
dart:mirrors is unsupported in Flutter/AOT because runtime reflection defeats tree shaking (the compiler can no longer prove what's unused), so the ecosystem uses build-time code generation instead.
Challenge 2 — Three conventions. Name the three things you must provide to make json_serializable generate code for a class.
Show solution
(1) The annotation (@JsonSerializable()), (2) the part 'file.g.dart'; directive, and (3) running dart run build_runner build. (Plus the factory/method that delegate to the generated _$... functions.)
Challenge 3 — Stale output. build_runner errors about conflicting outputs after you rename a class. What command fixes it?
Show solution
dart run build_runner build --delete-conflicting-outputs
It deletes stale generated files that conflict with the new build.
Challenge 4 — When does generation run? Does code generation add runtime cost to your shipped app? Explain.
Show solution
No. Generation is a build-time step that runs before compilation (Part 1) and just emits ordinary .dart source. By the time the app runs, the generated code is regular compiled Dart — there's no reflection or generation happening at runtime.
Challenge 5 — The macros lesson. In one or two sentences, explain why macros were cancelled and what replaced them.
Show solution
Macros had to re-run during incremental compilation, which slowed hot reload (Part 2) below Flutter's acceptable bar, so the team stopped work in January 2025. They pivoted to shipping augmentations (the augment keyword) and improving build_runner, which remains the standard.
Questions to test yourself
Q1 (basic). What is metaprogramming, and what's the most common thing it's used for in Dart?
Show answer
Code that inspects or generates code. In Dart it's used mainly to eliminate boilerplate — serialization (fromJson/toJson), data-class members (==, copyWith), DI/route wiring, and type-safe bindings — via build-time code generation.
Q2 (basic). What do build_runner and source_gen each do?
Show answer
build_runner is the build orchestrator that runs builders over your source and writes generated files (with caching). source_gen is a layer on top that makes writing generators easy, especially annotation-driven ones.
Q3 (intermediate). Why did Dart's ecosystem standardize on static codegen instead of runtime reflection?
Show answer
Runtime reflection (dart:mirrors) breaks AOT tree-shaking (Part 2) — if any class might be reflected at runtime, nothing can be proven unused, bloating binaries — so it's unsupported in Flutter. Generating plain Dart at build time keeps the program statically analyzable and tree-shakeable.
Q4 (intermediate). What role does the part directive play in code generation?
Show answer
The generated file is declared a part of your library (part 'x.g.dart';), which stitches the generated declarations into your library so they can access its private members (like _$FooFromJson). You wire in generated code by writing the part directive, not by editing the generated file.
Q5 (advanced). Explain how a source_gen generator actually produces code.
Show answer
It extends something like GeneratorForAnnotation<T>, receives the analyzed program as Elements (via the analyzer package), inspects classes/fields/types, and returns Dart source as a String. build_runner writes that string to a generated file and handles ordering/caching. It's "templating with full type information," done before compilation.
Q6 (advanced). Tie the macros cancellation back to a core theme of this series.
Show answer
Macros required re-executing during incremental compilation, which slowed hot reload — the JIT-powered (Part 2) sub-second iteration that defines Flutter's DX. The team prioritized that performance over the feature and cancelled macros (Jan 2025), pivoting to augmentations and a better build_runner. It exemplifies the series' theme: performance constraints shape what Dart can be.
Wrapping up
- Metaprogramming = code that writes code; in Dart it's done at build time, not via runtime reflection.
dart:mirrorsis avoided because reflection breaks AOT tree-shaking — performance dictated the whole approach.- The standard stack is
build_runner+source_gen, driven by three conventions: an annotation, apartdirective, and the build command (build/watch/--delete-conflicting-outputs). - The ecosystem (
json_serializable,freezed,riverpod_generator,drift,ffigen…) is all the same pattern; generation adds zero runtime cost. - Macros were cancelled in January 2025 because they slowed hot reload; Dart pivoted to augmentations (
augment) and improvingbuild_runner. Don't wait for macros — learn codegen.
That completes the concepts. Part 8 is the payoff: a 100-question mastery bank — hints and solutions — plus 10 coding mini-exercises and a capstone that ties the whole series together. Time to prove you own Dart internals.