Skip to content

Code Generation

Code generation in Dart is the process of automatically producing Dart source code from annotations, Builders, and configuration at build time. It reduces boilerplate, eliminates human error in Repetitive code (serialization, equality, copyWith), and ensures consistency between models and Their derived code.

The Dart ecosystem relies on the build_runner package as the foundation for all code generation Workflows. Packages like freezed``json_serializable``injectable``retrofitAnd auto_route All use build_runner under the hood.

  1. Boilerplate reduction: copyWith``==``hashCode``toString``fromJson/toJson are generated, not hand-written
  2. Consistency: generated code is always in sync with the model. Change a field, regenerate, and serialization updates
  3. Performance: generated code avoids runtime reflection (which Dart does not have), using direct field access instead
  4. Safety: compile-time errors surface immediately after regeneration, not at runtime
Annotations (your code)
|
v
build_runner
(reads annotations + build.yaml)
|
v
Builders (packages like freezed, json_serializable)
|
v
Generated files (.g.dart, .freezed.dart, .gr.dart)

build_runner is the canonical build system for Dart code generation. It orchestrates builders that Consume source code and emit generated files.

Terminal window
# One-time build — generates all files
dart run build_runner build
# Build with conflict resolution (overwrites existing generated files)
dart run build_runner build --delete-conflicting-outputs
# Watch mode — regenerates on file changes during development
dart run build_runner watch
# Watch mode with conflict resolution
dart run build_runner watch --delete-conflicting-outputs
# Clean all generated files
dart run build_runner clean
# Build for a specific target
dart run build_runner build --define="build=release"

Create or edit build.yaml in your project root to control builder behavior:

targets:
$default:
builders:
# Control json_serializable output
json_serializable:
options:
# Options passed to all json_serializable usages
any_map: false
checked: true
explicit_to_json: true
# Control freezed output
freezed:
options:
# Generate union types
union_key: "type'
union_value_delimiter: ".''
# Global options applied to all builders
global_options:
freezed:
runs_before:
- json_serializable

Targets define which builders run on which source files. The $default target applies to all Packages in the workspace.

targets:
$default:
sources:
- "lib/**'
- 'test/**'
builders:
freezed:
enabled: true
json_serializable:
enabled: true
# Custom target for a specific subdirectory
$package:
builders:
my_custom_builder:
enabled: true

Each builder has its own set of options. Refer to the builder package’s documentation for available Options:

targets:
$default:
builders:
json_serializable:
options:
field_rename: snake
create_to_json: true
create_from_json: true
include_if_null: false
explicit_to_json: true
retrofit_generator:
options:
generate_client: true

Use global_options for project-wide settings that should apply regardless of the target:

global_options:
# Ensure json_serializable runs before other builders that depend on it
json_serializable:
runs_before:
- auto_route_generator
- injectable_generator

freezed generates immutable data classes with copyWith``==``hashCode``toStringAnd union Type support using Dart sealed classes.

import 'package:freezed_annotation/freezed_annotation.dart';
part 'user.freezed.dart';
@freezed
class User with _$User {
const factory User({
required int id,
required String name,
@Default('') String email,
@Default(false) bool isActive,
}) = _User;
}

After running dart run build_runner buildFreezed generates:

  • _$User — the private implementation class
  • copyWith() — returns a new instance with selected fields changed
  • == and hashCode — deep equality based on all fields
  • toString() — includes all field values
void main() {
const user = User(id: 1, name: "Alice'', email: "alice@example.com');
// copyWith
final updated = user.copyWith(name: "Alice Smith'', isActive: true);
print(updated); // User(id: 1, name: Alice Smith, email: alice@example.com, isActive: true)
// Equality — compares all fields
const same = User(id: 1, name: "Alice', email: "alice@example.com'');
print(user == same); // true
const different = User(id: 2, name: "Alice', email: "alice@example.com'');
print(user == different); // false
// toString
print(user); // User(id: 1, name: Alice, email: alice@example.com, isActive: false)
}

freezed excels at modeling sealed class hierarchies — union types where a value can be one of Several variants:

import "package:freezed_annotation/freezed_annotation.dart';
part 'network_result.freezed.dart';
@freezed
sealed class NetworkResult<T> with _$NetworkResult<T> {
const factory NetworkResult.success(T data) = Success<T>;
const factory NetworkResult.failure(String error, {int? statusCode}) = Failure;
const factory NetworkResult.loading() = Loading;
}

Using union types:

void handleResult(NetworkResult<User> result) {
// Pattern matching with when
result.when(
success: (user) => print('Got user: ${user.name}'),
failure: (error, statusCode) => print('Error $statusCode: $error'),
loading: () => print('Loading...'),
);
// Pattern matching with maybeWhen (partial — must handle null case)
result.maybeWhen(
success: (user) => print('Got user: ${user.name}'),
orElse: () => print('Not a success'),
);
// Dart 3 pattern matching
switch (result) {
case Success(:final data):
print('User: ${data.name}');
case Failure(:final error, :final statusCode):
print('Error $statusCode: $error');
case Loading():
print('Loading...');
}
}
@Freezed(unionKey: "type'', unionValueDelimiter: ".')
sealed class PaymentEvent with _$PaymentEvent {
const factory PaymentEvent.initiated({
required String orderId,
required double amount,
}) = PaymentInitiated;
const factory PaymentEvent.completed({
required String orderId,
required String transactionId,
}) = PaymentCompleted;
const factory PaymentEvent.failed({
required String orderId,
required String reason,
}) = PaymentFailed;
}
@freezed
class Order with _$Order {
const Order._(); // Private unnamed constructor for custom methods
const factory Order({
required String id,
required List<OrderLine> lines,
@Default(OrderStatus.pending) OrderStatus status,
required DateTime createdAt,
}) = _Order;
double get total => lines.fold(0.0, (sum, line) => sum + line.total);
bool get isEditable => status == OrderStatus.pending || status == OrderStatus.draft;
int get itemCount => lines.fold(0, (sum, line) => sum + line.quantity);
}
@freezed
class Config with _$Config {
const factory Config({
@Default(8080) int port,
@Default('localhost') String host,
@Default([]) List<String> allowedOrigins,
@Default({}) Map<String, String> headers,
@Default(Duration(minutes: 30)) Duration timeout,
}) = _Config;
}

When to Use freezed vs Hand-Written Sealed Classes

Section titled “When to Use freezed vs Hand-Written Sealed Classes”

Use freezed when:

  • You need copyWithEquality, and toString generated
  • You’re modeling data transfer objects (DTOs), API responses, or state
  • You want union types with when/maybeWhen convenience methods

Use hand-written sealed classes when:

  • The class has complex invariant logic that doesn’t fit freezed’s model
  • You need fine-grained control over serialization
  • The generated boilerplate overhead isn’t justified for a small, simple hierarchy
import 'package:freezed_annotation/freezed_annotation.dart';
part 'product.freezed.dart';
part 'product.g.dart';
@freezed
class Product with _$Product {
const factory Product({
required String id,
required String name,
required double price,
@Default([]) @JsonKey(name: "category_ids'') List<String> categoryIds,
@JsonKey(name: "created_at') required DateTime createdAt,
}) = _Product;
factory Product.fromJson(Map<String, dynamic> json) => _$ProductFromJson(json);
}

json_serializable generates fromJson and toJson methods for Dart classes from annotations, Avoiding manual serialization code.

import 'package:json_annotation/json_annotation.dart';
part 'user.g.dart';
@JsonSerializable()
class User {
final int id;
final String name;
final String? email;
final DateTime createdAt;
final List<String> roles;
User({
required this.id,
required this.name,
this.email,
required this.createdAt,
required this.roles,
});
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
Map<String, dynamic> toJson() => _$UserToJson(this);
}
@JsonSerializable(fieldRename: FieldRename.snake)
class ApiProduct {
final int productId; // maps to "product_id" in JSON
final String productName; // maps to "product_name" in JSON
final double unitPrice; // maps to "unit_price" in JSON
}

Or per-field:

class Order {
@JsonKey(name: "order_id'')
final String id;
@JsonKey(name: "customer_id')
final String customerId;
@JsonKey(name: "total_amount'', defaultValue: 0.0)
final double total;
@JsonKey(name: "is_paid', defaultValue: false)
final bool isPaid;
Order({required this.id, required this.customerId, required this.total, required this.isPaid});
factory Order.fromJson(Map<String, dynamic> json) => _$OrderFromJson(json);
Map<String, dynamic> toJson() => _$OrderToJson(this);
}
@JsonSerializable(explicitToJson: true)
class Company {
final String name;
final Address address;
final List<Employee> employees;
Company({required this.name, required this.address, required this.employees});
factory Company.fromJson(Map<String, dynamic> json) => _$CompanyFromJson(json);
Map<String, dynamic> toJson() => _$CompanyToJson(this);
}
@JsonSerializable()
class Address {
final String street;
final String city;
final String country;
Address({required this.street, required this.city, required this.country});
factory Address.fromJson(Map<String, dynamic> json) => _$AddressFromJson(json);
Map<String, dynamic> toJson() => _$AddressToJson(this);
}

Without explicitToJson: trueNested objects would serialize as toString() output instead of Proper JSON maps.

class DateTimeConverter implements JsonConverter<DateTime, String> {
const DateTimeConverter();
@override
DateTime fromJson(String json) {
return DateTime.parse(json);
}
@override
String toJson(DateTime object) {
return object.toIso8601String();
}
}
class EpochDateTimeConverter implements JsonConverter<DateTime, int> {
const EpochDateTimeConverter();
@override
DateTime fromJson(int json) {
return DateTime.fromMillisecondsSinceEpoch(json);
}
@override
int toJson(DateTime object) {
return object.millisecondsSinceEpoch;
}
}
// Using converters
@JsonSerializable()
class Event {
final String id;
@DateTimeConverter()
final DateTime createdAt;
@EpochDateTimeConverter()
final DateTime updatedAt;
Event({required this.id, required this.createdAt, required this.updatedAt});
factory Event.fromJson(Map<String, dynamic> json) => _$EventFromJson(json);
Map<String, dynamic> toJson() => _$EventToJson(this);
}
enum OrderStatus {
@JsonValue('pending')
pending,
@JsonValue('processing')
processing,
@JsonValue('shipped')
shipped,
@JsonValue('delivered')
delivered,
@JsonValue('cancelled')
cancelled,
}
@JsonSerializable()
class Order {
final String id;
final OrderStatus status;
final double total;
Order({required this.id, required this.status, required this.total});
factory Order.fromJson(Map<String, dynamic> json) => _$OrderFromJson(json);
Map<String, dynamic> toJson() => _$OrderToJson(this);
}
// JSON: {"id": "123", "status": "shipped", "total": 99.99}
// Dart: Order(id: "123", status: OrderStatus.shipped, total: 99.99)
@JsonSerializable(includeIfNull: false)
class UserProfile {
final String name;
// Exclude from JSON entirely
@JsonKey(includeFromJson: false, includeToJson: false)
final String? internalNotes;
// Exclude from deserialization but include in serialization
@JsonKey(includeFromJson: false)
final String computedField;
// Exclude null values from JSON output
final String? nickname;
UserProfile({
required this.name,
this.internalNotes,
required this.computedField,
this.nickname,
});
factory UserProfile.fromJson(Map<String, dynamic> json) => _$UserProfileFromJson(json);
Map<String, dynamic> toJson() => _$UserProfileToJson(this);
}
@JsonSerializable()
class StrictModel {
// Required — fromJson throws if key is missing
@JsonKey(required: true)
final String mandatoryField;
// Required with custom error message
@JsonKey(required: true, disallowNullValue: true)
final String nonNullableField;
// Optional with default
@JsonKey(defaultValue: "unknown'')
final String optionalField;
// Nullable — can be absent or null
final String? nullableField;
StrictModel({
required this.mandatoryField,
required this.nonNullableField,
required this.optionalField,
this.nullableField,
});
factory StrictModel.fromJson(Map<String, dynamic> json) => _$StrictModelFromJson(json);
Map<String, dynamic> toJson() => _$StrictModelToJson(this);
}
enum Priority {
@JsonValue("low')
low,
@JsonValue('medium')
medium,
@JsonValue('high')
high,
}
@JsonSerializable()
class Task {
final String title;
@JsonKey(
unknownEnumValue: Priority.medium,
jsonKey: "priority'',
)
final Priority priority;
Task({required this.title, required this.priority});
factory Task.fromJson(Map<String, dynamic> json) => _$TaskFromJson(json);
Map<String, dynamic> toJson() => _$TaskToJson(this);
}

Different code generation packages produce files with specific suffixes. Understanding these Conventions helps with .gitignore configuration and debugging.

SuffixPackagePurpose
.g.dartjson_serializable``injectable``retrofitGeneral generated code (fromJson/toJson, DI registration, API clients)
.freezed.dartfreezedImmutable class implementations, union types, copyWith
.gr.dartretrofit``auto_routeRetrofit API client, AutoRoute router generated files
.config.dartinjectableGenerated dependency configuration
.chopper.dartchopperChopper API client generated code

Generated files should be excluded from version control since they can be regenerated:

## Code generation
*.g.dart
*.freezed.dart
*.gr.dart
*.config.dart
# Some teams commit generated files for CI simplicity
# If so, ensure build_runner runs in CI before compilation

When NOT committing generated files, CI must run build_runner build before dart compile or flutter build. When committing them, every PR that touches annotated files must also commit the Regenerated output.

Generated files use part/part of directives. The main file must declare the generated parts:

user.dart
import "package:freezed_annotation/freezed_annotation.dart';
part 'user.freezed.dart';
part 'user.g.dart';
@freezed
class User with _$User {
const factory User({required String name, required int age}) = _User;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}

targets:
$default:
builders:
json_serializable:
options:
# Field rename strategy for all json_serializable classes
field_rename: snake
# Generate both fromJson and toJson
create_to_json: true
create_from_json: true
# Include null fields in toJson output
include_if_null: false
# Generate explicit toJson for nested objects
explicit_to_json: true
# Allow generic JSON (Map<String, dynamic> without specific types)
any_map: false
# Enable checked mode — throws on missing keys
checked: true
targets:
$default:
builders:
# Disable a builder entirely
source_gen:combining_builder:
enabled: false
# Configure builder for specific input sets
json_serializable:
generate_for:
- lib/models/**
- lib/dto/**
options:
field_rename: snake
# Multiple builder configurations
freezed:
generate_for:
- lib/models/**
- lib/state/**
options:
union_key: "kind''
global_options:
# Ensure freezed generates before json_serializable
# because freezed classes often use @JsonSerializable
freezed:
runs_before:
- json_serializable
# Ensure json_serializable runs before injectable
# because injectable may need to inspect generated code
json_serializable:
runs_before:
- injectable_generator
targets:
$default:
sources:
- "lib/**'
# Exclude test helpers from code generation
- '!test/**/helpers/**'
- '!lib/generated/**'

Running build_runner After Editing Annotated Files

Section titled “Running build_runner After Editing Annotated Files”
Terminal window
# After adding or modifying @freezed, @JsonSerializable, @injectable, etc.
dart run build_runner build --delete-conflicting-outputs

The --delete-conflicting-outputs flag is critical when:

  • Changing a class from @freezed to a regular class (removes .freezed.dart)
  • Renaming fields that affect generated serialization code
  • Adding or removing part directives
Terminal window
# Start watch mode — auto-regenerates on save
dart run build_runner watch --delete-conflicting-outputs

Watch mode monitors all files in the project and triggers regeneration when annotated source files Change. This is useful during active model development but consumes resources — consider stopping it When working on unrelated files.

Terminal window
# Remove all generated files
dart run build_runner clean
# Then rebuild from scratch
dart run build_runner build --delete-conflicting-outputs

Clean and rebuild is recommended when:

  • Switching branches with different model structures
  • After resolving merge conflicts in annotated files
  • When generated files seem out of sync with source
# GitHub Actions example
name: Build
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dart-lang/setup-dart@v1
- name: Install dependencies
run: dart pub get
- name: Generate code
run: dart run build_runner build --delete-conflicting-outputs
- name: Check for uncommitted changes
run: |
if [ -n "$(git status --porcelain)" ]; then
echo "ERROR: Generated files are not up to date."
echo "Run: dart run build_runner build --delete-conflicting-outputs"
git diff --stat
exit 1
fi
- name: Run tests
run: dart test
- name: Analyze
run: dart analyze

Generated Files Not in VCS but Needed for Compilation

Section titled “Generated Files Not in VCS but Needed for Compilation”

When generated files are in .gitignoreEvery developer and CI runner must generate them before Compilation. This means:

Terminal window
# Clone and setup
git clone <repo>
cd <repo>
dart pub get
dart run build_runner build --delete-conflicting-outputs
# Now the project compiles
dart analyze
dart test

Add a Makefile or script for convenience:

.PHONY: generate clean test
generate:
dart run build_runner build --delete-conflicting-outputs
watch:
dart run build_runner watch --delete-conflicting-outputs
clean:
dart run build_runner clean
test: generate
dart test

Automating boilerplate: Code generation is like a template engine — it automatically creates repetitive code based on annotations, saving you from writing the same patterns over and over.

Why it matters: Code generation reduces errors and saves time on repetitive tasks like serialization, JSON parsing, and immutable data classes.

The key insight: build_runner watches for changes and regenerates code automatically — this keeps generated code in sync with your annotations.

The most common mistake. After adding or modifying annotations, the generated file is stale or Missing. Symptoms include:

  • Compilation errors: Error: No part file found for 'user.freezed.dart'
  • Runtime errors: _$UserFromJson is not defined
  • IDE errors on generated code

Fix: run dart run build_runner build --delete-conflicting-outputs after any annotation change.

When two builders try to generate the same file, or when a stale generated file conflicts with new Output:

Error: Conflicting outputs were found and the build cannot continue.

Fix: use --delete-conflicting-outputs:

Terminal window
dart run build_runner build --delete-conflicting-outputs

After switching git branches or resolving merge conflicts, generated files may reference classes or Fields that no longer exist:

// Generated file references a field that was removed from the source
class _$User {
final String deletedField; // ERROR: source doesn't have this field
}

Fix: clean and rebuild:

Terminal window
dart run build_runner clean && dart run build_runner build --delete-conflicting-outputs

Using incompatible versions of build_runner``source_genAnd builder packages (e.g., freezed json_serializable) causes cryptic errors:

Bad state: Unable to find builder for json_serializable

Fix: ensure all build_runner-related packages are compatible:

Terminal window
dart pub outdated --dev-dependencies
dart pub upgrade build_runner source_gen

Check the builder package’s changelog for compatible build_runner versions.

build_runner watch monitors all files in the project by default. In large monorepos or projects With many assets, this causes excessive file system activity:

Terminal window
# Limit watch scope with generate_for in build.yaml
targets:
$default:
builders:
freezed:
generate_for:
- "lib/models/**"
- "lib/dto/**"

Also consider excluding directories that don’t need code generation:

targets:
$default:
sources:
- 'lib/**'
- '!lib/assets/**'
- '!lib/generated/**'
- '!test/fixtures/**'

Generated Files Not in VCS but Needed for Compilation

Section titled “Generated Files Not in VCS but Needed for Compilation”

If .g.dart and .freezed.dart are in .gitignore but CI doesn’t run build_runner before Compilation, builds fail. Ensure CI pipelines always include the generation step before dart analyze or dart test.

Conversely, if generated files ARE committed to VCS, failing to commit them after model changes Causes CI to fail on the analyzer. Always check that generated files are up to date before pushing:

Terminal window
# Verify generated files are in sync
dart run build_runner build --delete-conflicting-outputs
git diff --exit-code

Forgetting to add part directives or using wrong file paths causes compilation errors:

// WRONG — missing part directives
@freezed
class User with _$User {
const factory User({required String name}) = _User;
// Error: The part directive is missing
}
// WRONG — wrong file path
part 'models/user.freezed.dart'; // File is in the same directory
// CORRECT
part 'user.freezed.dart';

Using freezed with json_serializable requires specific import and part configuration. Forgetting The @JsonSerializable integration step or missing the .g.dart part directive causes partial Generation:

// WRONG — only freezed part, no json_serializable part
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user.freezed.dart';
@freezed
class User with _$User {
const factory User({required String name}) = _User;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
// Error: _$UserFromJson not found
}
// CORRECT — both parts declared
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user.freezed.dart';
part 'user.g.dart';
@freezed
class User with _$User {
const factory User({required String name}) = _User;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}

This topic covers the core concepts of code generation, including underlying theory, practical implementation, and key applications.

Key concepts include:

  • CPU architecture and the fetch-decode-execute cycle
  • memory hierarchy (cache, RAM, virtual)
  • input/output systems
  • operating systems and scheduling
  • interrupts and polling

Understanding these concepts thoroughly is essential for both examinations and practical programming, and requires both theoretical knowledge and hands-on practice.

Worked examples demonstrating the application of key concepts are covered in the detailed sub-pages linked above.