Lesson 70 of 77 advanced

Code Generation: freezed, json_serializable & build_runner

Automate your model layer — immutable classes, JSON serialization, and unions with zero boilerplate

Open interactive version (quiz + challenge)

Real-world analogy

Writing model classes by hand is like hand-copying a legal contract every time you need a new one. You could do it, but you'll miss a field, mistype a name, or forget to update hashCode when you add a property. Code generation is like having a paralegal who reads your template, fills in every clause perfectly, and hands you a complete document every time. freezed is the template language, json_serializable is the JSON clause, and build_runner is the paralegal.

What is it?

Code generation in Dart uses build_runner to read annotations in your source code and produce generated .g.dart and .freezed.dart files. freezed creates immutable model classes with copyWith, equality, and unions. json_serializable creates JSON fromJson/toJson methods. Together, they eliminate thousands of lines of boilerplate while guaranteeing consistency.

Real-world relevance

In a production Flutter app with 50+ API models, code generation saves weeks of development time. Every model gets guaranteed deep equality (critical for BLoC/Riverpod state comparison), immutable copyWith (for state updates), and type-safe JSON parsing (no runtime surprises from API changes). The build_runner pipeline runs in CI to validate that generated code matches source annotations.

Key points

Code example

// freezed + json_serializable — production model example

import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:json_annotation/json_annotation.dart';

part 'user.freezed.dart';
part 'user.g.dart';

// Custom JSON converter for DateTime
class DateTimeConverter implements JsonConverter<DateTime, String> {
  const DateTimeConverter();

  @override
  DateTime fromJson(String json) => DateTime.parse(json);

  @override
  String toJson(DateTime date) => date.toIso8601String();
}

// Enum with custom JSON serialization
@JsonEnum(fieldRename: FieldRename.snake)
enum UserRole {
  @JsonValue('admin')
  admin,
  @JsonValue('editor')
  editor,
  @JsonValue('viewer')
  viewer,
}

// freezed model with JSON support
@freezed
class User with _$User {
  const factory User({
    required int id,
    @JsonKey(name: 'full_name') required String name,
    required String email,
    @Default(UserRole.viewer) UserRole role,
    @Default([]) List<String> permissions,
    @DateTimeConverter() required DateTime createdAt,
    @JsonKey(includeIfNull: false) String? avatarUrl,
  }) = _User;

  // Private constructor for custom getters
  const User._();

  // Custom getter on freezed class
  bool get isAdmin => role == UserRole.admin;

  // JSON factory
  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}

// freezed union type — API response
@freezed
class ApiResponse<T> with _$ApiResponse<T> {
  const factory ApiResponse.success(T data) = ApiSuccess<T>;
  const factory ApiResponse.error(String message, {int? code}) = ApiError<T>;
  const factory ApiResponse.loading() = ApiLoading<T>;
}

// Usage in production code
void handleResponse(ApiResponse<User> response) {
  // Exhaustive pattern matching
  final message = response.when(
    success: (user) => 'Welcome, ${user.name}!',
    error: (msg, code) => 'Error ${code ?? 0}: $msg',
    loading: () => 'Loading...',
  );
  print(message);
}

// copyWith — immutable updates
User updateUserRole(User user, UserRole newRole) {
  return user.copyWith(role: newRole);
  // Returns NEW User instance — original unchanged
}

// Deep equality — works out of the box
void equalityDemo() {
  final u1 = User(id: 1, name: 'Alice', email: 'a@b.com',
      createdAt: DateTime(2024));
  final u2 = User(id: 1, name: 'Alice', email: 'a@b.com',
      createdAt: DateTime(2024));
  print(u1 == u2);       // true — deep value equality
  print(u1.hashCode == u2.hashCode); // true
}

// JSON round-trip
void jsonDemo() {
  final json = {
    'id': 1,
    'full_name': 'Alice',
    'email': 'a@b.com',
    'role': 'admin',
    'created_at': '2024-01-15T10:30:00Z',
  };

  final user = User.fromJson(json);
  print(user.name);      // Alice (mapped from full_name)
  print(user.isAdmin);   // true

  final back = user.toJson();
  print(back['full_name']); // Alice
}

Line-by-line walkthrough

  1. 1. DateTimeConverter: custom JsonConverter that transforms between DateTime and ISO 8601 String — used with @DateTimeConverter() annotation
  2. 2. UserRole enum: @JsonValue maps Dart enum names to exact API string values — 'admin' not 'UserRole.admin'
  3. 3. User class: @freezed annotation triggers freezed code generation — `with _\$User` mixes in generated methods
  4. 4. @JsonKey(name: 'full_name'): maps Dart field 'name' to JSON key 'full_name' — API compatibility without renaming Dart code
  5. 5. @Default(UserRole.viewer): field defaults to viewer if not provided — works in both constructor and JSON deserialization
  6. 6. const User._(): private constructor allows adding custom getters (isAdmin) to a freezed class
  7. 7. ApiResponse union: three named constructors define the union variants — success, error, loading
  8. 8. response.when(): exhaustive pattern match — compiler ensures you handle all three cases
  9. 9. copyWith returns a NEW instance — original User is never mutated, critical for state management equality checks
  10. 10. equalityDemo: two User instances with same values are == true — freezed generates deep value equality
  11. 11. JSON round-trip: fromJson reads 'full_name' into name field, toJson writes it back — bidirectional mapping

Spot the bug

@freezed
class Product with _$Product {
  const factory Product({
    required int id,
    required String name,
    required double price,
  }) = _Product;

  // Custom method
  String get displayPrice => '$${price.toStringAsFixed(2)}';

  factory Product.fromJson(Map<String, dynamic> json) =>
      _$ProductFromJson(json);
}
Need a hint?
The class compiles but crashes at runtime with 'Cannot access instance member in initializer.' The issue is related to how freezed handles custom methods and the constructor.
Show answer
Bug: Adding custom getters or methods to a freezed class requires a private empty constructor: `const Product._();` must be added before any custom members. Without it, freezed's generated mixin can't properly initialize the class. Fix: add `const Product._();` between the factory constructor and the displayPrice getter. This is a freezed-specific requirement — the private constructor allows the generated _\$Product mixin to work correctly with your custom additions. Every freezed class with custom methods/getters needs this.

Explain like I'm 5

Imagine you're building with LEGO but every time you want a new house, you have to carve each brick by hand. Code generation is like having a brick-making machine — you describe the house (annotations), press a button (build_runner), and all the bricks appear perfectly shaped (generated code). freezed makes the bricks exactly the right size every time, and json_serializable makes bricks that can transform into shipping boxes (JSON) and back.

Fun fact

The freezed package generates so much code that a typical 20-line freezed class expands to 200+ lines of generated Dart. In a project with 100 models, that's 20,000 lines of code you never have to write, read, review, or debug. The generated code is also provably correct — it follows the same template every time, eliminating human typo errors in hashCode or == implementations.

Hands-on challenge

Create a complete model layer for a 'Product' entity using freezed + json_serializable: (1) A Product class with id, name, price (double), category (enum), tags (List), and createdAt (DateTime with custom converter). (2) A freezed union ProductState with loaded(List), loading(), and error(String message). (3) Add @JsonKey annotations for snake_case API field names. (4) Write the build.yaml with explicit_to_json and field_rename: snake. (5) Show how you'd use ProductState.when() in a widget.

More resources

Open interactive version (quiz + challenge) ← Back to course: Flutter Interview Mastery