Code Generation & Annotations
Introduction to Annotations in Dart
Annotations (also called metadata) are a way to attach additional information to your code. They do not change how your code runs, but they provide hints to tools, frameworks, and code generators. You have already seen built-in annotations like @override and @deprecated — in this lesson, you will learn how they work under the hood and how to create your own.
Annotations are one of Dart’s most powerful features for reducing boilerplate. By annotating a class, a code generator can automatically produce JSON serialization, immutable data classes, equality methods, copyWith, and much more.
const. The Dart runtime does not have reflection in AOT (ahead-of-time) compiled code, so annotations are primarily consumed by static analysis tools and code generators — not at runtime.Built-in Annotations
Dart and its core libraries provide several built-in annotations that you should already be familiar with. Let’s review them and understand when to use each one.
Built-in Annotations
import 'package:meta/meta.dart';
// @override — Indicates a method overrides a superclass method
class Animal {
String speak() => '...';
}
class Dog extends Animal {
@override
String speak() => 'Woof!';
}
// @deprecated — Marks a member as deprecated
class OldApi {
@deprecated
void oldMethod() => print('Use newMethod instead');
// More descriptive deprecation with Deprecated class
@Deprecated('Use processV2() instead. Will be removed in v3.0.0')
void process() => print('Old processing');
void processV2() => print('New processing');
}
// @pragma — Compiler hints (advanced)
class HeavyComputation {
@pragma('vm:prefer-inline')
int add(int a, int b) => a + b;
}
// From package:meta — additional annotations for analysis
class MyWidget {
@protected // Only subclasses should use this
void internalBuild() {}
@mustCallSuper // Subclasses must call super
void dispose() {
print('Cleaning up...');
}
@nonVirtual // Cannot be overridden
void coreLogic() {}
@visibleForTesting // Public only for test access
void resetState() {}
}
// @immutable — The class and all fields should be final
@immutable
class Point {
final double x;
final double y;
const Point(this.x, this.y);
}
package:meta to your dependencies to access annotations like @protected, @mustCallSuper, @immutable, and @visibleForTesting. The Dart analyzer understands these annotations and will produce warnings when they are violated.Creating Custom Annotations
Since annotations are just constant instances of classes, creating your own is straightforward. You define a class with a const constructor and use it with the @ prefix.
Custom Annotation Classes
// A simple marker annotation (no parameters)
class Todo {
final String message;
final String? assignee;
const Todo(this.message, {this.assignee});
}
// A route annotation for a web framework
class Route {
final String path;
final String method;
const Route(this.path, {this.method = 'GET'});
}
// A validation annotation
class Range {
final num min;
final num max;
const Range({required this.min, required this.max});
}
// A serialization annotation
class JsonField {
final String? name;
final bool ignore;
const JsonField({this.name, this.ignore = false});
}
// Using the custom annotations
@Todo('Add caching', assignee: 'Alice')
class UserService {
@Route('/users', method: 'GET')
Future<List<String>> getUsers() async => ['Alice', 'Bob'];
@Route('/users', method: 'POST')
Future<void> createUser(String name) async {
print('Creating $name');
}
}
class Product {
@JsonField(name: 'product_name')
final String name;
@JsonField()
final double price;
@JsonField(ignore: true)
final String internalId;
@Range(min: 0, max: 10000)
final int quantity;
Product(this.name, this.price, this.internalId, this.quantity);
}
build_runner and source_gen read annotations to generate code.Introduction to build_runner and Code Generation
Code generation in Dart is powered by build_runner — a build system that runs generators to produce .g.dart files from your source code. Generators read your annotations and produce boilerplate code automatically.
Setting Up build_runner
# pubspec.yaml
name: my_app
environment:
sdk: '>=3.0.0 <4.0.0'
dependencies:
json_annotation: ^4.8.0
dev_dependencies:
build_runner: ^2.4.0
json_serializable: ^6.7.0
# Terminal commands:
# Run the build once (generates .g.dart files)
dart run build_runner build
# Watch mode (auto-regenerate on file changes)
dart run build_runner watch
# Clean generated files
dart run build_runner clean
# Force rebuild (delete old outputs first)
dart run build_runner build --delete-conflicting-outputs
How Code Generation Works
The code generation pipeline follows a clear flow:
- You write a Dart class with annotations (e.g.,
@JsonSerializable()) - You add a
partdirective pointing to the generated file (e.g.,part 'user.g.dart';) - You run
dart run build_runner build - The generator reads your annotations and produces the
.g.dartfile - Your code can now use the generated methods
json_serializable: Automatic JSON Serialization
The json_serializable package is the most widely used code generator in the Dart ecosystem. It reads @JsonSerializable() annotations and generates fromJson and toJson methods automatically.
Basic json_serializable Usage
import 'package:json_annotation/json_annotation.dart';
// This tells Dart that 'user.g.dart' is part of this library
part 'user.g.dart';
@JsonSerializable()
class User {
final String name;
final String email;
final int age;
@JsonKey(name: 'is_active') // Map to different JSON key
final bool isActive;
@JsonKey(name: 'created_at')
final DateTime createdAt;
User({
required this.name,
required this.email,
required this.age,
required this.isActive,
required this.createdAt,
});
// These methods delegate to generated code
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
Map<String, dynamic> toJson() => _$UserToJson(this);
}
// After running: dart run build_runner build
// The file user.g.dart is generated with:
//
// User _$UserFromJson(Map<String, dynamic> json) => User(
// name: json['name'] as String,
// email: json['email'] as String,
// age: json['age'] as int,
// isActive: json['is_active'] as bool,
// createdAt: DateTime.parse(json['created_at'] as String),
// );
//
// Map<String, dynamic> _$UserToJson(User instance) => {
// 'name': instance.name,
// 'email': instance.email,
// 'age': instance.age,
// 'is_active': instance.isActive,
// 'created_at': instance.createdAt.toIso8601String(),
// };
Advanced json_serializable Features
Nested Objects, Enums, and Custom Converters
import 'package:json_annotation/json_annotation.dart';
part 'models.g.dart';
// Enum serialization
enum UserRole {
@JsonValue('admin')
admin,
@JsonValue('editor')
editor,
@JsonValue('viewer')
viewer,
}
// Custom converter for complex types
class TimestampConverter implements JsonConverter<DateTime, int> {
const TimestampConverter();
@override
DateTime fromJson(int timestamp) =>
DateTime.fromMillisecondsSinceEpoch(timestamp);
@override
int toJson(DateTime date) => date.millisecondsSinceEpoch;
}
@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);
}
@JsonSerializable(
explicitToJson: true, // Nested objects call toJson() too
fieldRename: FieldRename.snake, // Auto camelCase -> snake_case
includeIfNull: false, // Skip null fields in JSON output
)
class UserProfile {
final String firstName;
final String lastName;
final UserRole role;
final Address address; // Nested object
final List<String> skills; // List
@TimestampConverter() // Custom converter
final DateTime lastLogin;
@JsonKey(includeFromJson: false, includeToJson: false)
final String? temporaryToken; // Excluded from JSON
@JsonKey(defaultValue: 0)
final int loginCount; // Default if missing
UserProfile({
required this.firstName,
required this.lastName,
required this.role,
required this.address,
required this.skills,
required this.lastLogin,
this.temporaryToken,
this.loginCount = 0,
});
factory UserProfile.fromJson(Map<String, dynamic> json) =>
_$UserProfileFromJson(json);
Map<String, dynamic> toJson() => _$UserProfileToJson(this);
}
explicitToJson: true on classes with nested objects. Without it, nested objects are serialized using their default toString(), which produces Instance of 'Address' instead of the actual JSON. This is a very common mistake.freezed: Immutable Data Classes
The freezed package goes beyond JSON serialization. It generates immutable data classes with copyWith, ==, hashCode, toString, pattern matching support, and union types — all from a single annotated class.
Setting Up freezed
# pubspec.yaml
dependencies:
freezed_annotation: ^2.4.0
json_annotation: ^4.8.0
dev_dependencies:
build_runner: ^2.4.0
freezed: ^2.4.0
json_serializable: ^6.7.0
Immutable Data Class with freezed
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 String email,
required int age,
@Default(true) bool isActive,
List<String>? tags,
}) = _User;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}
// After code generation, you get:
void main() {
// Create an instance
final user = User(name: 'Alice', email: 'alice@test.com', age: 30);
// copyWith — create a modified copy
final updated = user.copyWith(name: 'Bob', age: 25);
print(updated); // User(name: Bob, email: alice@test.com, age: 25, ...)
// Deep equality (value-based, not reference-based)
final user2 = User(name: 'Alice', email: 'alice@test.com', age: 30);
print(user == user2); // true (same values)
// toString — readable output
print(user); // User(name: Alice, email: alice@test.com, age: 30, ...)
// JSON serialization
final json = user.toJson();
final restored = User.fromJson(json);
print(user == restored); // true
}
Union Types with freezed
One of freezed’s most powerful features is union types (sealed classes). These let you define a type that can be one of several variants, with exhaustive pattern matching.
Union Types (Sealed Classes) with freezed
import 'package:freezed_annotation/freezed_annotation.dart';
part 'result.freezed.dart';
@freezed
sealed class Result<T> with _$Result<T> {
const factory Result.success(T data) = Success<T>;
const factory Result.failure(String message, {int? code}) = Failure<T>;
const factory Result.loading() = Loading<T>;
}
// Usage with pattern matching
void handleResult(Result<String> result) {
// Exhaustive — compiler ensures all cases are handled
switch (result) {
case Success(:final data):
print('Got data: $data');
case Failure(:final message, :final code):
print('Error ($code): $message');
case Loading():
print('Loading...');
}
}
// Alternative: use the when/map methods generated by freezed
void handleResult2(Result<String> result) {
final message = result.when(
success: (data) => 'Success: $data',
failure: (message, code) => 'Error: $message',
loading: () => 'Loading...',
);
print(message);
}
void main() {
final results = <Result<String>>[
Result.success('Hello'),
Result.failure('Not found', code: 404),
Result.loading(),
];
for (final r in results) {
handleResult(r);
}
}
freezed union types for modeling states in your application (loading/success/error), network responses, navigation events, or any scenario where a value can be one of several distinct types. The exhaustive pattern matching ensures you never forget to handle a case.Creating Custom Code Generators
Understanding how to create a custom code generator helps you grasp how packages like json_serializable and freezed work internally. While you rarely need to build one from scratch, knowing the process is valuable.
Custom Generator Structure
// Step 1: Define your annotation (in a separate package)
// ====== my_annotation/lib/my_annotation.dart ======
class AutoToString {
final bool includePrivate;
const AutoToString({this.includePrivate = false});
}
// Step 2: Create the generator (in a separate package)
// ====== my_generator/lib/src/to_string_generator.dart ======
import 'package:analyzer/dart/element/element.dart';
import 'package:build/build.dart';
import 'package:source_gen/source_gen.dart';
import 'package:my_annotation/my_annotation.dart';
class AutoToStringGenerator extends GeneratorForAnnotation<AutoToString> {
@override
String generateForAnnotatedElement(
Element element,
ConstantReader annotation,
BuildStep buildStep,
) {
if (element is! ClassElement) {
throw InvalidGenerationSourceError(
'@AutoToString can only be applied to classes.',
element: element,
);
}
final className = element.name;
final includePrivate = annotation.read('includePrivate').boolValue;
final fields = element.fields.where((f) {
if (f.isStatic) return false;
if (!includePrivate && f.name.startsWith('_')) return false;
return true;
});
final fieldStrings = fields.map((f) => '${f.name}: \${${f.name}}').join(', ');
return '''
extension ${className}ToString on $className {
String toDebugString() => '$className($fieldStrings)';
}
''';
}
}
// Step 3: Create a builder (wires the generator into build_runner)
// ====== my_generator/lib/builder.dart ======
import 'package:build/build.dart';
import 'package:source_gen/source_gen.dart';
import 'src/to_string_generator.dart';
Builder autoToStringBuilder(BuilderOptions options) =>
SharedPartBuilder([AutoToStringGenerator()], 'auto_to_string');
// Step 4: Configure in build.yaml
// ====== my_generator/build.yaml ======
// builders:
// auto_to_string:
// import: "package:my_generator/builder.dart"
// builder_factories: ["autoToStringBuilder"]
// build_extensions: {".dart": [".auto_to_string.g.part"]}
// auto_apply: dependents
// build_to: cache
// applies_builders: ["source_gen|combining_builder"]
// Step 5: Use in your project
// ====== my_app/lib/models/product.dart ======
import 'package:my_annotation/my_annotation.dart';
part 'product.g.dart';
@AutoToString()
class Product {
final String name;
final double price;
final int quantity;
Product(this.name, this.price, this.quantity);
}
// After build_runner runs, product.g.dart contains:
// extension ProductToString on Product {
// String toDebugString() => 'Product(name: $name, price: $price, quantity: $quantity)';
// }
Practical Workflow: Complete json_serializable Project
Let’s walk through a complete real-world example of setting up and using json_serializable in a project with multiple model classes.
Complete Project Setup
// 1. pubspec.yaml
// name: blog_api
// dependencies:
// json_annotation: ^4.8.0
// http: ^1.1.0
// dev_dependencies:
// build_runner: ^2.4.0
// json_serializable: ^6.7.0
// 2. lib/models/post.dart
import 'package:json_annotation/json_annotation.dart';
import 'author.dart';
import 'comment.dart';
part 'post.g.dart';
@JsonSerializable(explicitToJson: true)
class Post {
final int id;
final String title;
final String body;
final Author author;
final List<Comment> comments;
@JsonKey(name: 'published_at')
final DateTime publishedAt;
@JsonKey(name: 'is_featured', defaultValue: false)
final bool isFeatured;
Post({
required this.id,
required this.title,
required this.body,
required this.author,
required this.comments,
required this.publishedAt,
this.isFeatured = false,
});
factory Post.fromJson(Map<String, dynamic> json) => _$PostFromJson(json);
Map<String, dynamic> toJson() => _$PostToJson(this);
}
// 3. lib/models/author.dart
import 'package:json_annotation/json_annotation.dart';
part 'author.g.dart';
@JsonSerializable()
class Author {
final int id;
final String name;
@JsonKey(name: 'avatar_url')
final String? avatarUrl;
Author({required this.id, required this.name, this.avatarUrl});
factory Author.fromJson(Map<String, dynamic> json) =>
_$AuthorFromJson(json);
Map<String, dynamic> toJson() => _$AuthorToJson(this);
}
// 4. lib/models/comment.dart
import 'package:json_annotation/json_annotation.dart';
part 'comment.g.dart';
@JsonSerializable()
class Comment {
final int id;
final String text;
@JsonKey(name: 'user_name')
final String userName;
@JsonKey(name: 'created_at')
final DateTime createdAt;
Comment({
required this.id,
required this.text,
required this.userName,
required this.createdAt,
});
factory Comment.fromJson(Map<String, dynamic> json) =>
_$CommentFromJson(json);
Map<String, dynamic> toJson() => _$CommentToJson(this);
}
// 5. Run: dart run build_runner build
// This generates post.g.dart, author.g.dart, comment.g.dart
// 6. Use in your code
import 'dart:convert';
void main() {
final jsonString = '''
{
"id": 1,
"title": "Getting Started with Dart",
"body": "Dart is a great language...",
"author": {"id": 10, "name": "Alice", "avatar_url": null},
"comments": [
{"id": 1, "text": "Great post!", "user_name": "Bob", "created_at": "2024-01-15T10:30:00Z"}
],
"published_at": "2024-01-15T09:00:00Z",
"is_featured": true
}
''';
final post = Post.fromJson(jsonDecode(jsonString));
print('${post.title} by ${post.author.name}');
print('Comments: ${post.comments.length}');
print('Featured: ${post.isFeatured}');
// Round-trip: convert back to JSON
final json = post.toJson();
print(const JsonEncoder.withIndent(' ').convert(json));
}
Best Practices for Code Generation
Code Generation Best Practices
// 1. ALWAYS commit generated files (.g.dart, .freezed.dart)
// to version control. This ensures CI/CD works without
// running build_runner.
// 2. Add build.yaml for project-wide configuration
// build.yaml:
// targets:
// $default:
// builders:
// json_serializable:
// options:
// explicit_to_json: true
// field_rename: snake
// 3. Use watch mode during development
// dart run build_runner watch
// 4. Create a barrel file for models
// lib/models/models.dart
// export 'user.dart';
// export 'post.dart';
// export 'comment.dart';
// 5. Handle nullable and default values properly
@JsonSerializable()
class Settings {
@JsonKey(defaultValue: 'en')
final String language;
@JsonKey(defaultValue: false)
final bool darkMode;
final String? optionalField; // Nullable = naturally optional
Settings({
this.language = 'en',
this.darkMode = false,
this.optionalField,
});
factory Settings.fromJson(Map<String, dynamic> json) =>
_$SettingsFromJson(json);
Map<String, dynamic> toJson() => _$SettingsToJson(this);
}
// 6. Use generic wrappers for API responses
@JsonSerializable(genericArgumentFactories: true)
class PaginatedResponse<T> {
final List<T> data;
final int total;
final int page;
@JsonKey(name: 'per_page')
final int perPage;
PaginatedResponse({
required this.data,
required this.total,
required this.page,
required this.perPage,
});
factory PaginatedResponse.fromJson(
Map<String, dynamic> json,
T Function(Object? json) fromJsonT,
) => _$PaginatedResponseFromJson(json, fromJsonT);
Map<String, dynamic> toJson(
Object? Function(T value) toJsonT,
) => _$PaginatedResponseToJson(this, toJsonT);
}
build.yaml to avoid repeating options like explicitToJson: true on every class. This keeps your model classes clean and consistent.Summary
In this lesson, you learned how to eliminate boilerplate through annotations and code generation:
- Built-in annotations —
@override,@deprecated,@pragma, andpackage:metaannotations for static analysis - Custom annotations — creating
constclasses to attach metadata to code elements - build_runner — the build system that powers Dart code generation with
build,watch, andcleancommands - json_serializable — automatic JSON serialization with
@JsonSerializable,@JsonKey, custom converters, and enum support - freezed — immutable data classes with
copyWith, value equality,toString, and union types for exhaustive pattern matching - Custom generators — the three-package architecture (annotation + generator + consumer) using
source_gen - Best practices — committing generated files, project-wide configuration, generic response wrappers