Dart Metaprogramming & Code Generation: Write Less, Ship More

  • Home
  • Dart
  • Dart Metaprogramming & Code Generation: Write Less, Ship More
Front
Back
Right
Left
Top
Bottom
STOP

Stop Writing the Same Code Twice

Here’s a confession: early in my career, I spent an entire afternoon writing JSON serialization code for 15 model classes. By hand. Individually.

Never again.

Dart’s code generation ecosystem is one of its most underappreciated superpowers. Once you understand `build_runner`, your models practically write themselves. This blog is for anyone — junior dev or senior architect — who’s tired of writing code that a machine should be writing for them.

HOW
How It Works

The Dart Build System

What Is build_runner?

The Dart build system (`build_runner`) is a good alternative to reflection (which has performance issues) and macros. Essentially, `build_runner` is a tool that provides an extendable mechanism for generating output files from input files.

Think of it this way: you write a small class with special annotations → `build_runner` reads it → generates the complete boilerplate class into a `.g.dart` file. You write the spec; Dart writes the implementation.

💻
# Run code generation once
dart run build_runner build

# Watch mode — regenerates on save
dart run build_runner watch

# Delete conflicts on regeneration
dart run build_runner build --delete-conflicting-outputs

How build_runner Processes Your Code

The generator is just a script that converts some input string to an output string (which happens to be valid Dart code) and writes it to disk.
CODE GENERATION

The Essential Code Generation Packages

json_serializable — Goodbye, Manual JSON Parsing

Before code gen:
📘
// Written by hand — error-prone and tedious
class User {
  final String id;
  final String name;
  final String email;
  
  User({required this.id, required this.name, required this.email});

  factory User.fromJson(Map<String, dynamic> json) => User(
    id: json['id'] as String,
    name: json['name'] as String,
    email: json['email'] as String,
  );

  Map<String, dynamic> toJson() => {
    'id': id,
    'name': name,
    'email': email,
  };
}
After code gen:
📘
// You write this 8-line class
import 'package:json_annotation/json_annotation.dart';

part 'user.g.dart'; // Generated file — don't edit!

@JsonSerializable()
class User {
  final String id;
  final String name;
  final String email;

  const User({required this.id, required this.name, required this.email});

  // Generated: factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
  // Generated: Map<String, dynamic> toJson() => _$UserToJson(this);
  Map<String, dynamic> toJson() => _$UserToJson(this);
}
📋
# pubspec.yaml
dependencies:
  json_annotation: ^4.9.0
dev_dependencies:
  build_runner: ^2.4.0
  json_serializable: ^6.8.0
Run `dart run build_runner build` and `user.g.dart` is created automatically. Add 30 more fields? Just add them to your class, re-run, done.

freezed — Immutable Data Classes Done Right

`freezed` is the gold standard for value objects, union types, and immutable models in Dart:
📘
import 'package:freezed_annotation/freezed_annotation.dart';

part 'auth_state.freezed.dart';

@freezed
class AuthState with _$AuthState {
  const factory AuthState.initial() = _Initial;
  const factory AuthState.loading() = _Loading;
  const factory AuthState.authenticated({
    required String userId,
    required String email,
  }) = _Authenticated;
  const factory AuthState.error({required String message}) = _Error;
}

// Usage — exhaustive pattern matching
void handleState(AuthState state) {
  switch (state) {
    case _Initial():
      print('Show splash screen');
    case _Loading():
      print('Show spinner');
    case _Authenticated(:final userId):
      print('Welcome, $userId!');
    case _Error(:final message):
      print('Error: $message');
  }
}

No more missing cases in your state management. The compiler catches them all.

injectable — Dependency Injection Without the Boilerplate

📘
import 'package:injectable/injectable.dart';

@injectable
class UserRepository {
  final ApiClient _client;
  UserRepository(this._client); // injectable handles this!
}

@singleton
class ApiClient {
  // Registered once, shared everywhere
}
Run `build_runner` → generates your entire DI container.
OWN CODE GEN

Writing Your Own Code Generator

Project Structure

Sometimes the built-in packages don’t cover your exact use case. Here’s how to write a basic custom generator.
💻
my_project/
├── annotations/          # Your annotation definitions
│   └── lib/
│       └── annotations.dart
├── generator/            # Code generator logic
│   ├── lib/
│   │   └── my_generator.dart
│   └── build.yaml
└── app/                  # Your actual app
    └── lib/
        └── models/

Define Your Annotation

📘
// generator/lib/my_generator.dart
import 'package:build/build.dart';
import 'package:source_gen/source_gen.dart';
import 'package:analyzer/dart/element/element.dart';
import 'package:annotations/annotations.dart';

class AutoToStringGenerator extends GeneratorForAnnotation<AutoToString> {
  @override
  String generateForAnnotatedElement(
    Element element,
    ConstantReader annotation,
    BuildStep buildStep,
  ) {
    if (element is! ClassElement) {
      throw InvalidGenerationSourceError('AutoToString only works on classes');
    }

    final className = element.name;
    final fields = element.fields.map((f) => '${f.name}: \$${f.name}').join(', ');

    // Generate extension with toString()
    return '''
extension ${className}ToString on $className {
  String toReadableString() => '$className($fields)';
}
''';
  }
}

// Register builder
Builder autoToStringBuilder(BuilderOptions options) =>
    SharedPartBuilder([AutoToStringGenerator()], 'auto_to_string');
📋
# generator/build.yaml
builders:
  auto_to_string:
    import: 'package:generator/my_generator.dart'
    builder_factories: ['autoToStringBuilder']
    build_extensions: {'.dart': ['.auto_to_string.g.part']}
    auto_apply: dependents
    build_to: cache
    applies_builders: ['source_gen|combining_builder']
ANNOTATION

Annotations and Reflection in Dart

How Annotations Work

Annotations are compile-time metadata markers. They don’t affect runtime behavior by themselves — they’re read by tools like `build_runner` during code generation.

📘
// Built-in annotations
@override         // Tell the compiler you're overriding a method
@deprecated       // Mark as deprecated — IDE warns users
@immutable        // Signal that a class shouldn't change

// From packages
@JsonSerializable()
@freezed
@injectable
@riverpod

// Custom
@AutoToString()
In Flutter, annotations serve as hints to tools like `build_runner` and code generators, indicating specific tasks or behaviors associated with the annotated code.
MIRRORS
and Why to Avoid It

Dart Mirrors (Reflection)

Dart has a reflection API (`dart:mirrors`) but it’s disabled in Flutter and AOT-compiled code due to performance costs. Code generation is the recommended alternative — it achieves the same results at compile time with zero runtime overhead.
MACROS
The Future of Metaprogramming

Dart Macros

Dart Macros were a much-anticipated language feature that would allow compile-time code transformation without a separate build step. However, as of Dart 3.7, the macros page was removed and the team indefinitely paused work on the feature.

 

This means `build_runner` remains the primary tool for code generation through 2027. The ecosystem around it — `json_serializable`, `freezed`, `injectable`, `retrofit` — is mature, battle-tested, and sufficient for virtually every production use case.
For business leader
 Code generation is not “cutting-edge risk” — it’s standard practice. Google uses these tools internally for their own Flutter products.
PERFORMANCE
The Future of Metaprogramming

Optimizing Code Generation Performance

Code generation can slow down large projects. Here are the key optimizations:
Use `generate_for` in build.yaml
Don’t feed every file to every generator:
📋
targets:
  $default:
    builders:
      json_serializable:
        generate_for:
          - lib/models/**.dart  # Only models, not all lib files
Keep packages small
**2. Keep packages small** — Dividing the project into smaller packages reduces the generators’ inputs, leading to faster code generation for each package.
Commit generated files

Commit `.g.dart` files to version control. New team members don’t need to run `build_runner` to compile. CI runs faster.

Explore project snapshots or discuss custom web solutions.

Any fool can write code that a computer can understand. Good programmers write code that humans can understand.

Martin Fowler, Refactoring: Improving the Design of Existing Code, 1999

Thank You for Spending Your Valuable Time

I truly appreciate you taking the time to read blog. Your valuable time means a lot to me, and I hope you found the content insightful and engaging!
Front
Back
Right
Left
Top
Bottom
FAQ's

Frequently Asked Questions

Yes, for most projects. Committing generated files means the project compiles immediately after `git clone`. It also makes code reviews easier since reviewers see exactly what was generated. The tradeoff is slightly larger repo size — worth it for team productivity.

Break your project into smaller packages using a monorepo approach (Flutter workspaces or Melos). Use `generate_for` to scope generators. Run `build_runner watch` during development instead of `build` — it only regenerates changed files.

The Dart team found that macros introduced significant compiler complexity, impacted IDE performance, and would have taken years to stabilize. They chose to focus on other high-value features. `build_runner` covers the vast majority of use cases that macros were intended to address.

`build` runs once and exits. Use it in CI/CD pipelines. `watch` runs continuously and regenerates files when your source changes — use it during local development. Always use `--delete-conflicting-outputs` if you switch between the two.

Absolutely. `build_runner` works with pure Dart CLI apps, server-side Dart, and packages. Everything demonstrated in this blog applies to any Dart project.

Comments are closed