Code Generation Deep Dive: Mastering build_runner with freezed and json_serializable

Code Generation Deep Dive: Mastering build_runner with freezed and json_serializable - Illustration 1

Illustration 1: Code Generation Deep Dive: Mastering build_runner with freezed and json_serializable

Introduction: The Boilerplate Blues and the Rise of Code Generation

Let’s be honest, as developers, we all have a love-hate relationship with boilerplate code. It’s the necessary evil that often clutters our beautiful logic and makes our codebases feel… well, a bit tedious. In the world of Dart and Flutter, especially when dealing with data, this boilerplate can quickly get out of hand. Think about defining immutable data classes, handling JSON serialization/deserialization, or even generating `toString()`, `hashCode`, and `equals()` methods. Manually writing and maintaining all of that is a recipe for bugs and wasted time.

Fortunately, the Dart ecosystem has a powerful solution: code generation. Tools like `build_runner` act as the orchestrator, while libraries like `freezed` and `json_serializable` provide the heavy lifting for specific tasks. If you’ve ever found yourself drowning in repetitive code for data models, this article is for you. We’re going to take a deep dive into how to effectively use `build_runner` with `freezed` and `json_serializable` to dramatically improve your development workflow, create more robust applications, and say goodbye to a significant chunk of that dreaded boilerplate.

By the end of this post, you’ll understand:

  • The core concepts behind `build_runner`, `freezed`, and `json_serializable`.
  • How to set up your project to leverage these powerful tools.
  • How to define immutable data models with `freezed` and integrate JSON handling with `json_serializable`.
  • Common mistakes to avoid and best practices to follow.

Ready to turbocharge your Dart and Flutter projects? Let’s get started!

>Understanding the Core Players: `build_runner`, `freezed`, and `json_serializable`

Before we dive into the ‘how’, let’s get a clear picture of ‘what’ we’re working with.

What is `build_runner`?

Think of `build_runner` as the build system for Dart. It’s responsible for finding and running build scripts (builders) that generate code. When you run commands like `flutter pub run build_runner build` or `flutter pub run build_runner watch`, `build_runner` scans your project for specific annotations or configurations and triggers the relevant code generators. It’s the central hub that makes all of this automated code generation possible.

Key features of `build_runner`:

  • Builder Discovery: It finds packages that define builders.
  • Task Orchestration: It manages the dependencies between different build steps.
  • Incremental Builds: In watch mode, it intelligently rebuilds only what’s necessary, saving a lot of time.
  • Caching: It caches generated code, further speeding up subsequent builds.

What is `freezed`?

`freezed` is a Dart package that helps you create excellent, immutable state classes. It’s particularly powerful for creating union types (also known as discriminated unions or sealed classes), which are fantastic for representing different states or events in your application. Beyond that, `freezed` automatically generates:

  • A constructor for your class.
  • copyWith() methods, which are essential for immutable state management.
  • toString(), hashCode(), and equals() methods.
  • Pattern matching capabilities for union types.

The core idea is to define your data structure once, and `freezed` handles the rest, ensuring immutability and providing useful utilities.

What is `json_serializable`?

`json_serializable` is another code generation package that focuses specifically on JSON serialization and deserialization. It reads your Dart classes and generates `fromJson` factory constructors and `toJson` methods. This means you don’t have to manually write the logic to convert JSON strings into Dart objects and vice-versa, which is notoriously error-prone and tedious. It understands Dart types and can map them to JSON keys intelligently.

Key benefits:

  • Type Safety: Ensures that the generated code adheres to your defined types.
  • Performance: Generated code is generally more performant than manually written serialization logic.
  • Readability: Keeps your model classes clean and focused on data, not serialization details.

>Setting Up Your Project for Code Generation

To harness the power of `build_runner` with `freezed` and `json_serializable`, you need to add them as development dependencies in your `pubspec.yaml` file. This is crucial because these packages are only needed during the build process and not in your final application runtime.

# pubspec.yaml
default_package_version: 1.0.0
dependencies:
flutter: 
sdk: flutter
# ... other dependencies
freezed_annotation: ^2.4.1
json_annotation: ^4.8.1
dev_dependencies:
flutter_test:
sdk: flutter
build_runner: ^2.4.7
freezed: ^2.4.5
json_serializable: ^6.7.1
flutter:
uses-material-design: true

After updating your `pubspec.yaml`, run the following command in your terminal to fetch the new packages:

flutter pub get

Configuring `build.yaml` (Optional but Recommended)

While not strictly necessary for basic usage, a `build.yaml` file can give you more control over the build process. For `json_serializable`, you might want to configure how it handles nulls, default values, or specific field mapping. For `freezed`, it’s less common to need custom `build.yaml` configurations unless you’re doing very advanced things.

Here’s a simple `build.yaml` example to enable JSON conversion with `json_serializable` by default:

# build.yaml
targets:
$default:
builders:
source_gen|combining_builder:
options:
# Define the package for json_serializable
package: json_serializable
# Define the builder name
builder_name: json_serializable
# Define the input file extensions
input_extensions:
- .dart
# Define the output file extensions
output_extensions:
- .g.dart
# Options for json_serializable can be passed here as well
# For example, to enable ignore_unannotated, uncomment the line below:
# ignore_unannotated: true
# This is a more direct way to configure json_serializable
# without using combining_builder, which is often preferred.
# It ensures that json_serializable is run for files ending in .dart
# and produces output files ending in .g.dart.
json_serializable:
# This tells json_serializable to generatefromJson and toJson methods
# for classes that have the @JsonSerializable() annotation.
generate_from_json: true
generate_to_json: true
# For Freezed, you typically don't need explicit build.yaml configuration
# as its annotations are directly picked up by build_runner.
# However, if you wanted to set default options for freezed, you could do:
# freezed:
# options:
# merge_with_equals: true

The more common and often simpler approach is to let the annotations guide `build_runner`. For `json_serializable`, you’ll annotate your classes, and `build_runner` will find the builder. The `build.yaml` is more for fine-tuning.

Running the Builder

Once you have your dependencies set up and your models defined, you’ll run the code generation process. There are two main ways:

  • One-time build:
flutter pub run build_runner build

This command will scan your project, generate all necessary code, and then exit. It’s good for CI/CD pipelines or when you want to commit the generated code.

  • Watch mode:
flutter pub run build_runner watch

This is your development workhorse. `build_runner watch` will keep running in the background, watching for file changes. Whenever you save a file that affects generated code (like your model definitions), it will automatically re-run the necessary builders and update the generated files. This provides a near-instantaneous feedback loop, which is incredibly productive.

Important Note: Always commit your generated files (usually ending in `.g.dart` and `.freezed.dart`). They are part of your application’s state and are necessary for the project to build correctly without `build_runner` running all the time.

>Deep Dive into `freezed`: Immutable Data Models Made Easy

Immutability is a cornerstone of robust application state management. With immutable objects, you can’t change their state after creation, which prevents many common bugs related to unexpected side effects. `freezed` makes creating these immutable models a breeze.

Basic `freezed` Usage

Let’s create a simple `User` model:

// lib/models/user.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user.freezed.dart'; // This is crucial!
@freezed
class User with _$User {
const factory User({
required String id,
required String name,
int? age, // Optional field
}) = _User;
}

Here’s what’s happening:

  • `import ‘package:freezed_annotation/freezed_annotation.dart’;`: Imports the necessary annotations.
  • `part ‘user.freezed.dart’;`: This tells Dart that `freezed` will generate a part file named `user.freezed.dart`. You MUST include this line.
  • `@freezed`: This annotation is the magic wand for `freezed`. It tells the builder to process this class.
  • `class User with _$User`: Defines your class and mixes in `_$User`, which will be generated.
  • `const factory User({…}) = _User;`: This is the factory constructor for your immutable class. `freezed` will generate the actual implementation in `user.freezed.dart`. The `_User` part signifies the concrete implementation generated by `freezed`.
  • `required String id, required String name, int? age`: These are your class properties. `required` ensures they must be provided during instantiation. Optional fields are marked with `?`.

After defining this, run flutter pub run build_runner watch. You’ll see a new file `user.freezed.dart` appear in the same directory. This file contains the generated implementation, including constructors, `copyWith`, `toString`, `hashCode`, and `equals`.

Using `copyWith`

Since `User` is immutable, you can’t directly change its properties. Instead, you use the generated `copyWith` method to create a *new* instance with modified properties:


final user1 = User(id: '1', name: 'Alice');
final user2 = user1.copyWith(age: 30); // Creates a new User object
print(user1.name); // 'Alice'
print(user2.name); // 'Alice'
print(user2.age); // 30
// You can also copy with nulls
final user3 = user2.copyWith(age: null);
print(user3.age); // null

Union Types (Sealed Classes)

`freezed` shines when dealing with state or event variations. You can define a class that can be one of several types:


// lib/models/api_response.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'api_response.freezed.dart';
@freezed
abstract class ApiResponse with _$$ApiResponse {
const factory ApiResponse.loading() = _Loading;
const factory ApiResponse.success(T data) = _Success;
const factory ApiResponse.error(String message) = _Error;
}

Now, `ApiResponse` can be either `ApiResponse.loading()`, `ApiResponse.success(someData)`, or `ApiResponse.error(‘some message’)`. This is incredibly useful for managing UI states based on API calls.

You can then use pattern matching (using `when` or `maybeWhen` methods generated by `freezed`) to handle these different states:


void handleResponse(ApiResponse response) {
response.when(
loading: () => print('Loading...'),
success: (data) => print('Success: $data'),
error: (message) => print('Error: $message'),
);
}
// Example usage:
handleResponse(ApiResponse.loading());
handleResponse(ApiResponse.success('User data'));
handleResponse(ApiResponse.error('Network failed'));

The `when` method ensures that you handle all possible states. If you miss one, the compiler will complain. This is a massive advantage for preventing bugs.

>Deep Dive into `json_serializable`: Seamless JSON Handling

Dealing with JSON data is a daily task for most mobile developers. `json_serializable` automates the creation of `fromJson` and `toJson` methods, saving you from tedious manual implementation.

Basic `json_serializable` Usage

Let’s add JSON capabilities to our `User` model:


// lib/models/user.dart (updated)
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:json_annotation/json_annotation.dart';
part 'user.freezed.dart';
part 'user.g.dart'; // Crucial for json_serializable!
@freezed
class User with _$User {
const factory User({
required String id,
required String name,
int? age,
}) = _User;
factory User.fromJson(Map json) => _$UserFromJson(json);
}

Here’s what’s new:

  • `import ‘package:json_annotation/json_annotation.dart’;`: Imports the necessary annotations for JSON handling.
  • `part ‘user.g.dart’;`: This tells `json_serializable` to generate a part file named `user.g.dart`.
  • `factory User.fromJson(Map json) => _$UserFromJson(json);`: This is the factory constructor that `json_serializable` will generate the implementation for.

You also need to annotate your class with `@JsonSerializable()` (or a similar annotation, depending on your `build.yaml` configuration) for `json_serializable` to pick it up. A common setup is to use `@JsonSerializable()` on the class itself if you want it to be serializable.

However, a cleaner approach, especially when combining with `freezed`, is to annotate the fields directly or rely on conventions. `json_serializable` is quite smart. If you have a class that is *already* being processed by `freezed` (which implicitly handles the structure), `json_serializable` can often infer what to do. To explicitly enable JSON generation for a `freezed` class, you can use a combination of annotations or rely on `build.yaml`. A very common pattern is to use `json_serializable` directly on the factory constructor’s parameters or the class itself if not using `freezed`’s factory constructors.

Let’s refine the `User` example to make it clearer for `json_serializable`:


// lib/models/user.dart (even better)
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:json_annotation/json_annotation.dart';
part 'user.freezed.dart';
part 'user.g.dart';
// Annotate the class itself to tell json_serializable to process it.
// If you have specific configurations, use @JsonSerializable(fieldRename: FieldRename.snake)
@freezed
@JsonSerializable(createFactory: true, createToJson: true) // Explicitly enable factory and toJson
class User with _$User {
// The factory constructor now uses the 'final' keyword for properties.
// Freezed handles the constructor, and JsonSerializable will hook into it.
const factory User({
required String id,
required String name,
int? age,
}) = _User;
// This factory is still needed for JsonSerializable to generate fromJson.
// The _$UserFromJson call will be the generated implementation.
factory User.fromJson(Map json) => _$UserFromJson(json);
// JsonSerializable will also generate a toJson method if createToJson is true.
// You don't need to declare it here.
}

With this setup, running `build_runner watch` will generate `user.g.dart`, containing the `_$UserFromJson(json)` implementation and a `_$UserToJson(User instance)` implementation.

Handling Common JSON Scenarios

  • Field Renaming: JSON keys often use snake_case, while Dart uses camelCase.

// lib/models/product.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:json_annotation/json_annotation.dart';
part 'product.freezed.dart';
part 'product.g.dart';
@freezed
@JsonSerializable(fieldRename: FieldRename.snake, createFactory: true, createToJson: true)
class Product with _$Product {
const factory Product({
required String productId,
required String productName,
@JsonKey(name: 'release_date') DateTime? releaseDate,
}) = _Product;
factory Product.fromJson(Map json) => _$ProductFromJson(json);
}

In this example:

  • `@JsonSerializable(fieldRename: FieldRename.snake)` tells `json_serializable` to automatically convert camelCase Dart properties to snake_case JSON keys and vice-versa. So, `productId` becomes `product_id`.
  • `@JsonKey(name: ‘release_date’)` is used to explicitly map `releaseDate` to `release_date` in JSON. This is useful when the naming convention doesn’t cover all cases or when you need a specific mapping.
  • Custom Data Types: For types that `json_serializable` doesn’t know how to handle by default (like `DateTime` or custom enums), you can provide custom converters.

// lib/models/event.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:json_annotation/json_annotation.dart';
part 'event.freezed.dart';
part 'event.g.dart';
// Assuming you have a custom converter for DateTime
// For example, to convert to/from ISO 8601 strings:
DateTimeConverter() {
DateTime fromJson(String json) => DateTime.parse(json);
String toJson(DateTime object) => object.toIso8601String();
}
@freezed
@JsonSerializable(createFactory: true, createToJson: true)
class Event with _$Event {
const factory Event({
required String name,
@DateTimeConverter() required DateTime timestamp,
}) = _Event;
factory Event.fromJson(Map json) => _$EventFromJson(json);
}

Here, `@DateTimeConverter()` (assuming `DateTimeConverter` is defined elsewhere and implements `JsonConverter`) tells `json_serializable` to use your custom logic for serializing and deserializing `DateTime` objects.

>The Magic Happens: Combining `freezed` and `json_serializable`

The real power comes from using these two libraries together. `freezed` gives you immutable data models with excellent utilities, and `json_serializable` handles the data conversion. When combined, you get clean, robust, and maintainable data structures.

Let’s take our `User` model and make it fully ready for both immutability and JSON:


// lib/models/user.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:json_annotation/json_annotation.dart';
part 'user.freezed.dart';
part 'user.g.dart';
// This annotation tells json_serializable to generate fromJson and toJson methods.
// FieldRename.snake handles common snake_case to camelCase conversion.
@freezed
@JsonSerializable(fieldRename: FieldRename.snake, createFactory: true, createToJson: true)
class User with _$User {
const factory User({
// Using @JsonKey to explicitly map JSON keys if they differ from Dart properties.
// Although with FieldRename.snake, 'userId' would map to 'user_id'.
// Let's assume the JSON key is 'user_id' and Dart property is 'userId'.
@JsonKey(name: 'user_id') required String userId,
required String name,
int? age,
}) = _User;
// Factory constructor for JSON deserialization.
factory User.fromJson(Map json) => _$UserFromJson(json);
// toJson method will be generated automatically because createToJson is true.
// You can optionally define it if you need custom logic:
// Map toJson() => _$UserToJson(this);
}

When you run `flutter pub run build_runner watch`:

  • `user.freezed.dart` will be generated with immutable properties, `copyWith`, `toString`, `hashCode`, and `equals`.
  • `user.g.dart` will be generated with the `_$UserFromJson` factory and the `_$UserToJson` method.

Now you can easily create and convert User objects:


// Creating an object
final newUser = User(userId: 'u123', name: 'Bob', age: 25);
// Creating a copy with changes
final updatedUser = newUser.copyWith(age: 26);
// Converting to JSON
final userJson = updatedUser.toJson(); 
// userJson will look like: {'user_id': 'u123', 'name': 'Bob', 'age': 26}
// Converting from JSON
final jsonString = '{"user_id": "u456", "name": "Charlie"}';
final jsonMap = jsonDecode(jsonString) as Map;
final anotherUser = User.fromJson(jsonMap);
print(anotherUser.userId); // u456
print(anotherUser.name); // Charlie
print(anotherUser.age); // null (because it wasn't in the JSON)

This combination provides a robust and efficient way to handle your data models. It enforces immutability, simplifies state management, and automates error-prone JSON parsing.

>Common Pitfalls and How to Avoid Them

Even with powerful tools, developers can stumble. Here are some common issues you might encounter:

  • Forgetting `part ‘…’` directives: If `build_runner` can’t find the `part` directives in your `.freezed.dart` or `.g.dart` files, it won’t generate anything. Always ensure these are present and correct.
  • Not running `build_runner watch` or forgetting to commit generated files: Your app won’t compile if the generated code is missing. Always run `build_runner watch` during development and commit the generated `.freezed.dart` and `.g.dart` files to your repository.
  • Incorrect Annotations or Configuration: Double-check the annotations like `@freezed`, `@JsonSerializable`, and `@JsonKey`. Ensure they are applied correctly and that your `build.yaml` (if used) has the right configurations.
  • Type Mismatches in JSON: If your JSON data has a string where an integer is expected (or vice-versa), `json_serializable` will throw an error during runtime when `fromJson` is called. Always validate your JSON data or use appropriate type converters.
  • Conflicting Builders: Sometimes, multiple packages might try to generate code for the same file. `build_runner` usually handles this, but complex setups might require careful builder configuration in `build.yaml`.
  • Ignoring Unannotated Fields: By default, `json_serializable` might ignore fields that aren’t explicitly annotated. You can control this behavior using `ignore_unannotated: true` in your `build.yaml` or `JsonSerializable` annotation options if you want it to error out on unannotated fields.
  • Over-reliance on `fieldRename`: While convenient, `fieldRename` can mask subtle differences between your Dart model and the JSON payload. For critical fields, using `@JsonKey(name: ‘…’)` provides explicit clarity and avoids ambiguity.

>Advanced Scenarios and Tips

Once you’re comfortable with the basics, here are some advanced techniques:

  • Default Values in `freezed`:

@freezed
class Settings with _$Settings {
const factory Settings({
@Default(false) bool notificationsEnabled,
@Default(10) int volume,
}) = _Settings;
// ... fromJson/toJson if needed
}
// Usage:
final defaultSettings = Settings(); // notificationsEnabled: false, volume: 10
final customSettings = Settings(notificationsEnabled: true);

The `@Default()` annotation sets a default value for a field if it’s not provided during instantiation or deserialization.

  • `copyWith` on Union Types:

You can use `copyWith` on union types, but it works slightly differently. You’d typically use `copyWith` on the specific subtype:


final loadingState = ApiResponse.loading();
// To change it to success:
final successState = loadingState.copyWith.success(data: 'New Data');
// If you want to change properties within a state, you can use maybeWhen with a copyWith:
final oldState = ApiResponse.success('Old Data');
final newState = oldState.maybeWhen(
success: (data) => oldState.copyWith.success(data: 'Updated Data'),
orElse: () => oldState, // Keep the original state if it's not success
);

This can get a bit verbose. For complex state transitions, consider dedicated state management solutions.

  • `build_runner` in CI/CD:

In your CI/CD pipeline, you’ll typically run `flutter pub run build_runner build`. This generates all the code. You should then commit these generated files. On your build server, you don’t need `build_runner watch`. The generated files are sufficient.

  • External Link:

For more in-depth understanding of Dart’s code generation capabilities and the `build` package ecosystem, the official Dart Build Optimization documentation is an excellent resource.

>Conclusion: Embrace Automation for Better Development

Mastering `build_runner` with `freezed` and `json_serializable` is a significant step towards writing cleaner, more maintainable, and less error-prone Dart and Flutter code. By automating the generation of boilerplate code for immutable data models and JSON serialization, you free up valuable development time and reduce the surface area for bugs.

We’ve covered:

  • The roles of `build_runner`, `freezed`, and `json_serializable`.
  • How to set them up in your project.
  • Creating immutable data classes with `freezed`.
  • Handling JSON with `json_serializable`.
  • Combining them for powerful data modeling.
  • Common pitfalls and advanced tips.

Start incorporating these tools into your projects today. The initial setup might seem like a small hurdle, but the long-term benefits in terms of code quality, developer productivity, and reduced debugging time are immense. Happy coding!

🎥 Video Tutorial

Để lại một bình luận