Dart Advanced Features

Code Generation & Annotations

50 min Lesson 15 of 16

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.

Note: Annotations in Dart are compile-time constants. They are instances of classes whose constructors are 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);
}
Tip: Add 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);
}
Note: Custom annotations by themselves do nothing at runtime — they are inert metadata. They become powerful when a code generator or analysis tool reads them and produces code or warnings. In the next sections, we will see how 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:

  1. You write a Dart class with annotations (e.g., @JsonSerializable())
  2. You add a part directive pointing to the generated file (e.g., part 'user.g.dart';)
  3. You run dart run build_runner build
  4. The generator reads your annotations and produces the .g.dart file
  5. 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);
}
Warning: Always set 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);
  }
}
Tip: Use 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)';
// }
Note: Creating a code generator requires three packages: (1) the annotation package (consumed by users), (2) the generator package (consumed by build_runner), and (3) the user’s app package. This separation prevents the heavy analyzer/build dependencies from being included in your runtime code.

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);
}
Tip: Configure project-wide defaults in 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, and package:meta annotations for static analysis
  • Custom annotations — creating const classes to attach metadata to code elements
  • build_runner — the build system that powers Dart code generation with build, watch, and clean commands
  • 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