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.
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 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
freezed — Immutable Data Classes Done Right
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
}
Writing Your Own Code Generator
Project Structure
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']
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()
Dart Mirrors (Reflection)
Dart Macros
Â
For business leader
Optimizing Code Generation Performance
Use `generate_for` in build.yaml
targets:
$default:
builders:
json_serializable:
generate_for:
- lib/models/**.dart # Only models, not all lib files
Keep packages small
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.
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!
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