Skip to content
Blog

Dart Macros: Compile-Time Code Generation Comes to the Language

Dart macros promised in-language compile-time code generation with deep semantic introspection. The program was paused in January 2025 over hygiene, analyzer integration, and compile-time performance. This post explains what macros were designed to do, why they did not ship, what the 2026 roadmap replaces them with, and how to keep using build_runner today.

Published on • October 11, 2026

AI Assistant

Every serious Dart codebase eventually grows a .g.dart dependency tree. You annotate a class with @JsonSerializable(), run dart run build_runner build, and wait while a separate process reads your source, writes new Dart files, and hands them back to the analyzer. It works — json_serializable, freezed, and riverpod_generator have shipped production apps for years — but the friction is real: generation delays on every edit, generated-file sprawl, annotation reads that only make sense after a build step, and an entire class of bugs that live in the gap between your source and the files the compiler actually sees.

Dart macros were the language team’s answer: move that metaprogramming inside the compiler, where a macro could introspect declarations with full semantic understanding and emit code as part of the same compilation. The feature spent roughly two years in experimental prototyping — a JsonCodable preview shipped in Dart 3.4, and package:macros exposed authoring APIs tied to the SDK. Then, in January 2025, the Dart team published an update on Dart macros and data serialization announcing they had stopped work. If you have seen blog posts or conference talks promising macros “next year”, this post is the honest status check — what the feature was designed to do, why it stalled, and what the Dart and Flutter 2026 roadmap actually commits to instead.

In this tutorial, you will learn how to:

  • Distinguish macros from build_runner/source_gen code generation and why the difference matters
  • Describe the two macro kinds — declaration macros and definition macros with compile-time execution
  • Explain how macros introspected declarations and emitted code through augmentation libraries
  • Understand why the original program was paused: hygiene, analyzer integration, and compile-time performance costs
  • Read the 2026 roadmap correctly: augmentations and build_runner improvements ship; macros do not
  • Keep your current toolchain productive with json_serializable, freezed, and riverpod_generator
  • Prepare your code and habits so a future static-metaprogramming feature lands with minimal churn

Key technologies: Dart SDK, package:build_runner, package:source_gen, json_serializable, freezed, riverpod_generator, Dart augmentations (experimental), the Dart analyzer.

Prerequisites

  • A Dart or Flutter project that already uses at least one code generator, or willingness to scaffold one with flutter create
  • Familiarity with annotations and part/part of directives
  • dart CLI on your PATH (dart --version)

Macros vs build_runner: the distinction that motivated the feature

Today’s pipeline is external and unchanged. build_runner orchestrates builders; source_gen reads annotated elements through the analyzer, emits Dart source into *.g.dart or *.freezed.dart files, and your library imports them with part directives. The compiler then compiles those files — not the generator’s internal model of your code:

# The edit loop that macros were meant to remove.
dart run build_runner build --delete-conflicting-outputs
dart analyze
flutter test

Macros proposed something different. A macro would be a piece of ordinary Dart code, applied via the existing annotation syntax, executed inside the compiler (or the language server), with access to an introspection API over your declarations. The macro would contribute code to an augmentation library — a first-class concept the compiler merges with your hand-written library before type checking completes. No separate build step, no generated files on disk to check in or .gitignore, and the analyzer would see the same augmented program the compiler does.

build_runner + source_genMacros (as designed)
Where it runsSeparate process, before compilationInside the compiler / language server
Input to codegenAnalyzer elements, re-read by a builderLive introspection API over declarations
OutputNew .g.dart files on diskIn-memory augmentation libraries
Analyzer viewSees generated files after a buildSees the augmented program directly
Edit loopRe-run the generator, then analyzeExpand during the same compilation

The motivating use case was always data. Serialization was the most requested issue across the Dart and Flutter trackers, and the team said outright that better data handling was the primary motivation for macros — JsonCodable was the proof-of-concept preview in Dart 3.4:

@JsonCodable()
class Vehicle {
  final String description;
  final int wheels;
  Vehicle(this.description, this.wheels);
}

// A macro would synthesize fromJson/toJson from the fields,
// integrated into the same compilation.

The same shape applied to immutable data classes, DI registration, and routing tables. The appeal over build_runner was not just speed — it was that the generated members would be part of the class from the compiler’s point of view, with go-to-definition, error messages, and refactoring behaving as if you had written them yourself.

The two macro kinds

The design (preserved in the macros feature specification under working/macros) organised macros around what phase of compilation they participate in. The two kinds you will see referenced are:

Declaration macros. These introspect a declaration and add new declarations — fields, methods, constructors, top-level types — without supplying executable bodies in every case. A declaration macro applied to Vehicle could add Map<String, dynamic> toJson(); signatures derived from the field list.

Definition macros (compile-time execution). These go further: they can supply the implementations — function bodies, initializers, even wrapping existing bodies with code injected before or after. This is the “compile-time execution” half of the design: the macro body runs during compilation and its output becomes part of the program’s semantics.

Under the hood both ran in ordered phases — types, then declarations, then definitions — so that mutually referencing classes (the Human/Pet JSON example in the spec) could all declare toJson before any body was generated. Macro authors wrote normal Dart classes marked with the macro keyword, implementing interfaces like ClassDeclarationsMacro, and called builder methods such as declareInClass with Code objects rather than raw strings, so identifier resolution survived the trip into the augmentation library. A declaration macro, sketched in the shape the specification described, looked roughly like this:

macro class AddToJson implements ClassDeclarationsMacro {
  const AddToJson();

  @override
  Future<void> buildDeclarationsForClass(
    ClassDeclaration clazz,
    ClassDeclarationBuilder builder,
  ) async {
    for (final field in clazz.fields) {
      builder.declareInClass(DeclarationCode.fromString(
        "Map<String, Object?> toJson() => {...};",
      ));
    }
  }
}

The interface names, the macro keyword, and the phased builder methods are preserved in the feature specification but are not a stable, shippable API — treat the snippet as a historical illustration of the design, not code to depend on.

How introspection and augmentations worked

Introspection was the crux. Macros could read names, members, and type annotations of the declaration they were applied to, and — crucially — could traverse from a type annotation to the declaration it referred to. That deep semantic introspection (beyond mere syntax, which the team judged insufficient for their goals) is exactly what made the feature powerful for serialization and what made it expensive to build. In the final architecture, everything a macro contributed was emitted into an augmentation library, with deterministic ordering rules so that the IDE, the compiler, and the test runner all produced identical output for the same inputs.

Hygiene — ensuring generated identifiers resolve to what the macro author meant, not whatever happens to be in scope at the application site — was handled through Code and Identifier objects that carried resolution context. Bare strings in generated code resolved at the application site; explicitly constructed Identifiers kept their origin. Getting this wrong is a classic source of subtle metaprogramming bugs, and the design spent significant effort on it. Hold that thought: it is part of why the feature did not converge.

Why the program was paused

The January 2025 update is blunt: “each time we solved a major technical hurdle, we saw new ones pop up”, and the team was “not seeing macros converging anytime soon toward a feature we are comfortable shipping, with the quality and developer-time performance we want.” The stated reasons:

ProblemWhat it broke
Semantic introspection costsDeep semantic queries at compile time inflated compilation time far beyond the team’s budget
Analyzer and IDE regressionStatic analysis, code completion, and navigation degraded when macros were active — the editor experience regressed rather than improved
Incremental compilation / hot reloadMacro expansion interfered with keeping stateful hot reload hot, a core Dart value proposition
Hygiene and integration complexityIdentifier hygiene, ordering across phases, and analyzer/compiler parity remained hard to get right at production quality

The opportunity cost was explicit: shipping macros meant not shipping other features the community had asked for. The team chose to redirect effort toward better data support via bespoke language features, build_runner improvements, and augmentations — the one piece of the macros prototype that stood on its own. Dart removed the experimental macros documentation page, and package:macros remains an experimental artifact of that era, not a stable API.

Current status on the 2026 roadmap

Read the Flutter & Dart 2026 roadmap carefully. Under “Modern syntax & compiled performance” it says the plan is to ship primary constructors (which arrived in Dart 3.13) and augmentations to simplify code generation, continue improving build_runner and Dart/Wasm compilation, and refactor the analyzer for large-scale performance. Macros are not listed. Augmentations — prototyped as part of macros — are the shipping successor for the code-generation story, still tracked in the dart-lang/language repository behind an experimental flag (introduced at 3.6.0 in the SDK’s experimental features) and under active design discussion.

The honest summary: macros are not shipped, not stable, and not on the near-term roadmap. Augmentations are planned for 2026 but are a different, more focused feature — letting declarations be split across augmenting files — not the full introspecting macro system. Long-term interest in general static metaprogramming remains open as a tracking issue, but the team’s public position is that macros will not ship in the foreseeable future.

What augmentations actually look like, per the specification in the language repository, is the augment keyword applied to a declaration that already exists in the same library — typically in a part file:

// user.dart — the introductory declaration.
class User {
  final String name;
  final int age;
}

// user.g.dart — an augmentation, conceptually similar to today's
// generated part, but recognised by the language itself.
part of 'user.dart';

augment class User {
  factory User.fromJson(Map<String, dynamic> json) => User(
        name: json['name'] as String,
        age: json['age'] as int,
      );
}

Augmentations can add members to classes, append to enums, add to with/implements clauses, and fill in missing bodies. They share the augmented library’s top-level scope and private names. Crucially, they do not include the arbitrary semantic introspection that macros used to discover field lists — a generator that inspects fields and emits an augmentation file would still be doing that part with the analyzer, outside the language feature. That is why the practical expectation is augmentations making codegen output cleaner and more first-class, not eliminating codegen outright.

What stays the same today

Nothing about your current toolchain changes because of the pause. The generators that power Dart and Flutter apps keep working on build_runner:

  • json_serializable — JSON fromJson/toJson via source_gen
  • freezed — immutable classes, unions, copyWith
  • riverpod_generator — compile-time provider generation
  • built_value, drift, retrofit, and the rest of the build_runner ecosystem

If anything, the pause improves their future: the roadmap commits to build_runner performance work (the team identified improvements tracked in the build issue tracker), and augmentations are being designed to make existing code generators better rather than replace them overnight. Your *.g.dart files are safe; treat any talk of “macros making build_runner obsolete” as premature.

Preparing your code for what actually ships

You cannot migrate to a feature that does not exist, but you can make your codebase ready for augmentations and faster codegen with habits that pay off regardless:

  1. Centralise generated-code conventions. Keep build.yaml configuration and generator versions in one package or directory so upgrades are single-point changes.
  2. Separate hand-written code from generated output. The .g.dart / .freezed.dart part pattern already does this; resist the temptation to hand-edit generated files — augmentations will not rescue edits the generator overwrites.
  3. Prefer annotations that describe intent, not implementation. @JsonSerializable() with explicit field renaming maps cleanly onto whatever generation mechanism comes next; annotations coupled to a generator’s internal quirks do not.
  4. Adopt primary constructors where they help. Dart 3.13’s primary constructors reduce the boilerplate generators historically existed to remove, and they compose with the augmentation direction the roadmap describes.
  5. Watch the augmentations tracker, not macro rumours. The language repository’s augmentations label is the authoritative signal for when a real migration path appears.

A practical migration outlook: when augmentations land in a stable release, expect popular packages to adopt them incrementally — first as an alternative output mode alongside .g.dart files, then eventually as the default for new major versions. Plan for coexistence, not a flag day.

Common pitfalls

  • Building on the experimental macros API. package:macros and the macro keyword are relics of the cancelled program. Do not introduce them into production packages; the API is not a stable commitment.
  • Confusing augmentations with macros. Augmentations add declarations to existing ones across files; they do not ship the deep, arbitrary semantic introspection macros promised. A @JsonSerializable()-style feature built on augmentations would still likely involve a generation step behind the scenes.
  • Blocking work on “wait for macros”. The team was explicit: not shipping in the foreseeable future. If your roadmap has a dependency on macros, replace it with build_runner today.
  • Believing every “Dart metaprogramming is back” headline. Status claims should be checked against the Dart blog and the language repository; the experimental macros page was removed from dart.dev for a reason.
  • Ignoring build-time costs you can fix now. Build_runner speed improvements are on the roadmap, but you also control caching, builder filtering, and CI regen checks in your own repos — those wins do not require any language feature.

Further reading