kanta13jp1Dart Metaprogramming — build_runner, Source Gen, and Dart 3 Macros Explained Every Flutter...
Every Flutter developer has used json_serializable or freezed, but few understand what actually happens when dart run build_runner build runs. Understanding Dart's code generation stack unlocks the ability to eliminate repetitive boilerplate in your own projects. This guide goes from "using existing generators" to "writing your own Builder" to a peek at the experimental Dart 3 Macros API.
Your .dart source files
│ @YourAnnotation decorates a class
▼
build_runner ────────────── watches for file changes
│
│ invokes Builder implementations
▼
source_gen ───────────────── convenience layer over build
│
│ GeneratorForAnnotation parses AST via `analyzer`
▼
Generated .g.dart files ──── included via `part` directives
▼
Final compiled Dart code
Key packages:
| Package | Role |
|---|---|
build_runner |
Build system orchestrator |
build |
Low-level Builder interface |
source_gen |
Higher-level GeneratorForAnnotation wrapper |
analyzer |
Dart AST analysis (used inside generators) |
Before writing your own generator, use the mainstream packages to understand what well-designed code generation looks like.
# pubspec.yaml
dependencies:
json_annotation: ^4.9.0
freezed_annotation: ^2.4.0
dev_dependencies:
build_runner: ^2.4.0
json_serializable: ^6.8.0
freezed: ^2.5.0
// lib/models/task.dart
import 'package:json_annotation/json_annotation.dart';
part 'task.g.dart'; // ← generated by build_runner
@JsonSerializable(
fieldRename: FieldRename.snake, // camelCase ↔ snake_case
includeIfNull: false,
explicitToJson: true,
)
class Task {
const Task({
required this.id,
required this.title,
required this.isDone,
required this.createdAt,
this.tags,
});
final String id;
final String title;
@JsonKey(name: 'is_done')
final bool isDone;
@JsonKey(name: 'created_at')
final DateTime createdAt;
final List<String>? tags;
factory Task.fromJson(Map<String, dynamic> json) => _$TaskFromJson(json);
Map<String, dynamic> toJson() => _$TaskToJson(this);
}
# One-shot generation
dart run build_runner build --delete-conflicting-outputs
# Watch mode — keep running during development
dart run build_runner watch --delete-conflicting-outputs
What build_runner generates (task.g.dart):
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
part of 'task.dart';
Task _$TaskFromJson(Map<String, dynamic> json) => Task(
id: json['id'] as String,
title: json['title'] as String,
isDone: json['is_done'] as bool,
createdAt: DateTime.parse(json['created_at'] as String),
tags: (json['tags'] as List<dynamic>?)
?.map((e) => e as String)
.toList(),
);
Map<String, dynamic> _$TaskToJson(Task instance) {
final val = <String, dynamic>{
'id': instance.id,
'title': instance.title,
'is_done': instance.isDone,
'created_at': instance.createdAt.toIso8601String(),
};
void writeNotNull(String key, dynamic value) {
if (value != null) val[key] = value;
}
writeNotNull('tags', instance.tags);
return val;
}
// lib/models/auth_state.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'auth_state.freezed.dart';
part 'auth_state.g.dart';
@freezed
sealed class AuthState with _$AuthState {
const factory AuthState.loading() = _Loading;
const factory AuthState.authenticated({required User user}) = _Authenticated;
const factory AuthState.unauthenticated() = _Unauthenticated;
const factory AuthState.error({required String message}) = _Error;
}
// Usage — exhaustive pattern matching
switch (state) {
case _Loading() => const CircularProgressIndicator(),
case _Authenticated(:final user) => HomeScreen(user: user),
case _Unauthenticated() => const LoginPage(),
case _Error(:final message) => ErrorView(message: message),
}
Let's build a @Loggable annotation that auto-generates a toLogString() extension on any annotated class.
packages/
loggable_gen/ ← separate package for the generator
pubspec.yaml
lib/
src/
loggable_annotation.dart
loggable_generator.dart
loggable_gen.dart
build.yaml
lib/
models/
task.dart ← uses @loggable
// packages/loggable_gen/lib/src/loggable_annotation.dart
class Loggable {
const Loggable({this.tag = ''});
final String tag; // optional override for the log tag
}
const loggable = Loggable();
// packages/loggable_gen/lib/src/loggable_generator.dart
import 'package:analyzer/dart/element/element.dart';
import 'package:build/build.dart';
import 'package:source_gen/source_gen.dart';
import 'loggable_annotation.dart';
class LoggableGenerator extends GeneratorForAnnotation<Loggable> {
@override
String generateForAnnotatedElement(
Element element,
ConstantReader annotation,
BuildStep buildStep,
) {
if (element is! ClassElement) {
throw InvalidGenerationSourceError(
'@Loggable can only decorate classes.',
element: element,
);
}
final className = element.name;
final customTag = annotation.read('tag').stringValue;
final tag = customTag.isEmpty ? className : customTag;
// Build a comma-separated field log expression
final fieldExpressions = element.fields
.where((f) => !f.isStatic && !f.isSynthetic)
.map((f) => '${f.name}: \$${f.name}')
.join(', ');
return '''
extension \$${className}Logging on $className {
String toLogString() => '[$tag] $fieldExpressions';
// ignore: avoid_print
void log() => print(toLogString());
}
''';
}
}
Builder loggableBuilder(BuilderOptions options) =>
SharedPartBuilder([LoggableGenerator()], 'loggable');
# packages/loggable_gen/build.yaml
builders:
loggable:
import: "package:loggable_gen/loggable_gen.dart"
builder_factories: ["loggableBuilder"]
build_extensions: {".dart": [".loggable.g.part"]}
auto_apply: dependents
build_to: source
applies_builders: ["source_gen|combining_builder"]
// lib/models/task.dart
import 'package:loggable_gen/loggable_gen.dart';
import 'package:json_annotation/json_annotation.dart';
part 'task.g.dart';
@loggable // ← our custom annotation
@JsonSerializable()
class Task {
const Task({
required this.id,
required this.title,
required this.isDone,
});
final String id;
final String title;
final bool isDone;
factory Task.fromJson(Map<String, dynamic> json) => _$TaskFromJson(json);
Map<String, dynamic> toJson() => _$TaskToJson(this);
}
// After build_runner:
// task.log();
// prints: [Task] id: abc-123, title: Write blog post, isDone: false
Routing is another area where code generation eliminates an entire class of bugs (path typos, missing parameters).
// lib/routes/app_routes.dart
part 'app_routes.g.dart';
@TypedGoRoute<HomeRoute>(path: '/')
class HomeRoute extends GoRouteData {
const HomeRoute();
@override
Widget build(BuildContext context, GoRouterState state) =>
const HomePage();
}
@TypedGoRoute<TaskDetailRoute>(path: '/tasks/:taskId')
class TaskDetailRoute extends GoRouteData {
const TaskDetailRoute({required this.taskId});
final String taskId;
@override
Widget build(BuildContext context, GoRouterState state) =>
TaskDetailPage(taskId: taskId);
}
@TypedGoRoute<SettingsRoute>(
path: '/settings',
routes: [
TypedGoRoute<ThemeSettingsRoute>(path: 'theme'),
],
)
class SettingsRoute extends GoRouteData {
const SettingsRoute();
@override Widget build(_, __) => const SettingsPage();
}
class ThemeSettingsRoute extends GoRouteData {
const ThemeSettingsRoute();
@override Widget build(_, __) => const ThemeSettingsPage();
}
// Navigation — fully type-safe, no strings
// HomeRoute().go(context);
// TaskDetailRoute(taskId: task.id).push(context);
// ThemeSettingsRoute().push(context);
Dart Macros represent a fundamentally different approach — they run at compile time and modify the AST directly, with no .g.dart files at all.
// Requires Dart >=3.5 and --enable-experiment=macros
// pubspec.yaml: environment: { sdk: '>=3.5.0 <4.0.0' }
import 'package:macros/macros.dart';
/// Automatically adds fromJson/toJson to any class
macro class JsonCodable implements ClassDeclarationsMacro {
const JsonCodable();
@override
Future<void> buildDeclarationsForClass(
ClassDeclaration clazz,
MemberDeclarationBuilder builder,
) async {
final fields = await builder.fieldsOf(clazz);
final name = clazz.identifier.name;
// Generate fromJson factory
final fromArgs = fields.map((f) {
final fname = f.identifier.name;
return "$fname: json['$fname'] as ${f.type.code}";
}).join(',\n ');
builder.declareInType(DeclarationCode.fromString('''
factory $name.fromJson(Map<String, dynamic> json) =>
$name(
$fromArgs,
);
'''));
// Generate toJson
final toArgs = fields.map((f) {
final fname = f.identifier.name;
return "'$fname': $fname";
}).join(',\n ');
builder.declareInType(DeclarationCode.fromString('''
Map<String, dynamic> toJson() => {
$toArgs,
};
'''));
}
}
// Usage — no `part` directive needed!
@JsonCodable()
class User {
const User({required this.id, required this.email});
final String id;
final String email;
// fromJson and toJson are injected at compile time
}
Current status of Dart Macros (as of 2030):
| Aspect | Status |
|---|---|
| Availability | Experimental (--enable-experiment=macros) |
| IDE support | Partial — analysis server integration in progress |
| Production readiness | Not recommended yet for production apps |
| Timeline | Stable API targeted for Dart 4.x |
# .github/workflows/codegen.yml
name: Code Generation
on: [push, pull_request]
jobs:
codegen:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: subosito/flutter-action@v2
with:
flutter-version: '3.24.x'
- name: Install dependencies
run: flutter pub get
- name: Run build_runner
run: dart run build_runner build --delete-conflicting-outputs
- name: Check for uncommitted generated files
run: |
git diff --exit-code || {
echo "Generated files are out of date. Run build_runner locally."
exit 1
}
| Practice | Reason |
|---|---|
Commit .g.dart files |
Enables IDE to work without running build_runner |
Use --delete-conflicting-outputs in CI |
Prevents stale generation errors |
| Keep annotations in a separate package | Avoids circular dependencies |
Use watch only during development |
Continuous build drains battery/CPU |
Add // coverage:ignore-file to .g.dart
|
Prevents false coverage reports |
Dart code generation evolves in three stages:
json_serializable, freezed, riverpod_generator eliminate the most common boilerplatesource_gen and analyzer
Builder for patterns unique to your codebaseMacros are the future, but build_runner is production-proven today. At Jibun Inc., go_router_builder + riverpod_generator + json_serializable together eliminate roughly 30% of what would otherwise be hand-written glue code.
Based on real implementation at Jibun Inc. (Flutter Web + Supabase).