← Back to blog
Dart Internals & Performance · Part 7 of 8
September 11, 202611 min read

Metaprogramming in Dart: build_runner, Code Generation & the Macros Story

DartPerformanceFlutter

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_runner are 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/toJson serialization
  • ==, hashCode, copyWith, toString for 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:mirrors is 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 part of your library: part 'user.g.dart';. The generator emits user.g.dart containing _$UserFromJson / _$UserToJson, and because it's a part, those private members are visible to your file. You import the result by writing the part directive — 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 .dart is 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 missing part directive, 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:

  1. Augmentations (shipping). A new augment keyword 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 on part glue).

    // 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};
    }
    
  2. 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.

  3. The ecosystem adapted. Notably, Freezed 3.0 shipped without its planned macro-based rewrite, staying on build_runner. The community's takeaway: build_runner is 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_gen well; 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:mirrors is 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, a part directive, 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 improving build_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.