Flutter Code Generation: Reducing Boilerplate with build_runner and Friends
If you've been writing Flutter apps for a while, you've probably noticed something: there's a lot of repetitive code. Whether it's creating model classes with JSON serialization, implementing immutable data classes, or writing equality methods, Flutter development often involves writing the same boilerplate code over and over again.
Thankfully, the Flutter community has embraced code generation as a powerful solution to this problem. Tools like build_runner, freezed, and json_serializable can automatically generate the code you need, saving you time and reducing the chance of errors.
In this article, we'll explore how code generation works in Flutter, why it's useful, and how to get started with some of the most popular code generation packages. By the end, you'll understand how to leverage these tools to write cleaner, more maintainable Flutter code.
What is Code Generation?
Code generation is the process of automatically creating source code from templates, annotations, or other source files. In Flutter, code generation typically works by analyzing your Dart code, looking for special annotations (like @JsonSerializable or @freezed), and then generating additional code based on those annotations.
The generated code is usually placed in separate files (often with a .g.dart or .freezed.dart extension) that are created when you run the code generator. These files are then imported alongside your original code, giving you access to the generated functionality.
Why Use Code Generation?
Before diving into the how, let's talk about the why. Code generation offers several compelling benefits:
- Less Boilerplate: You write less repetitive code, focusing on the logic that matters
- Fewer Errors: Generated code is consistent and less prone to human error
- Easier Maintenance: When you change your model, the generated code updates automatically
- Type Safety: Many code generation tools provide better type checking at compile time
- Performance: Generated code is often optimized and can be more efficient than handwritten alternatives
However, code generation isn't without its trade-offs. You'll need to run the generator whenever you change your code, and there's a learning curve to understanding how the tools work. But for most Flutter projects, especially those with complex data models, the benefits far outweigh the costs.
Setting Up build_runner
The foundation of Flutter code generation is build_runner, a tool that orchestrates the code generation process. It's developed by the Dart team and is the standard way to run code generators in Flutter projects.
To get started, add build_runner to your pubspec.yaml as a dev dependency:
dev_dependencies:
build_runner: ^2.4.0
Once installed, you can run code generation with:
flutter pub run build_runner build
This command will analyze your code, run all configured code generators, and create the necessary generated files. If you want the generator to watch for changes and rebuild automatically (useful during development), use:
flutter pub run build_runner watch
The watch command is particularly handy because it will automatically regenerate code whenever you save changes to your source files.
JSON Serialization with json_serializable
One of the most common use cases for code generation in Flutter is JSON serialization. Converting between Dart objects and JSON is a frequent requirement, especially when working with APIs. The json_serializable package makes this process much easier.
Let's see how it works with a practical example. First, add the necessary dependencies:
dependencies:
json_annotation: ^4.8.0
dev_dependencies:
build_runner: ^2.4.0
json_serializable: ^6.7.0
Now, let's create a simple user model:
import 'package:json_annotation/json_annotation.dart';
part 'user.g.dart';
@JsonSerializable()
class User {
final String name;
final String email;
final int age;
User({
required this.name,
required this.email,
required this.age,
});
factory User.fromJson(Map json) => _$UserFromJson(json);
Map toJson() => _$UserToJson(this);
}
Notice a few things here:
- We import
json_annotationand use the@JsonSerializable()annotation - We include a
partdirective pointing to the generated file (user.g.dart) - We define factory methods (
fromJsonandtoJson) that call generated functions (prefixed with_$)
After running build_runner, the generated file will contain the actual implementation of _$UserFromJson and _$UserToJson. This means you can now easily convert between JSON and your User object:
// Convert JSON to User object
final json = {'name': 'John', 'email': 'john@example.com', 'age': 30};
final user = User.fromJson(json);
// Convert User object to JSON
final userJson = user.toJson();
The generated code handles all the tedious mapping between JSON keys and object properties, including type conversions and null handling. If you need to customize the JSON keys (for example, if your API uses snake_case), you can use the @JsonKey annotation:
@JsonSerializable()
class User {
@JsonKey(name: 'user_name')
final String name;
@JsonKey(name: 'email_address')
final String email;
// ... rest of the class
}
Immutable Data Classes with freezed
Another popular code generation tool is freezed, which helps you create immutable data classes with minimal boilerplate. Immutable classes are great for state management and functional programming patterns because they can't be modified after creation, making your code more predictable and easier to reason about.
To use freezed, add it to your dependencies:
dependencies:
freezed_annotation: ^2.4.0
dev_dependencies:
build_runner: ^2.4.0
freezed: ^2.4.0
json_serializable: ^6.7.0
Here's how you can create an immutable data class with freezed:
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,
String? description,
}) = _Product;
factory Product.fromJson(Map json) => _$ProductFromJson(json);
}
This small amount of code generates a lot of functionality:
- Immutable properties: All fields are final and can't be changed
- CopyWith method: Create modified copies of the object
- Equality: Automatic implementation of
==andhashCode - toString: A readable string representation
- JSON serialization: When combined with
json_serializable
Here's how you'd use the generated copyWith method:
final product = Product(
id: '1',
name: 'Widget',
price: 9.99,
description: 'A great widget',
);
// Create a new product with updated price
final updatedProduct = product.copyWith(price: 12.99);
// Original product is unchanged
print(product.price); // 9.99
print(updatedProduct.price); // 12.99
The copyWith method is particularly useful in state management scenarios where you need to create new state objects rather than mutating existing ones.
Union Types and Sealed Classes
One of the most powerful features of freezed is its support for union types (also called sealed classes or sum types). This allows you to represent data that can be one of several different types, which is perfect for modeling states, results, or variant data structures.
For example, imagine you're building a weather app and need to represent different loading states:
@freezed
class WeatherState with _$WeatherState {
const factory WeatherState.loading() = _Loading;
const factory WeatherState.loaded({
required double temperature,
required String condition,
}) = _Loaded;
const factory WeatherState.error({
required String message,
}) = _Error;
}
Now you can use pattern matching (with when or maybeWhen) to handle each state:
final state = WeatherState.loaded(
temperature: 72.5,
condition: 'Sunny',
);
state.when(
loading: () => print('Loading weather...'),
loaded: (temp, condition) => print('$condition, $temp°F'),
error: (message) => print('Error: $message'),
);
This pattern is incredibly useful for state management because it forces you to handle all possible states, making your code more robust and less prone to bugs.
Best Practices and Tips
As you start using code generation in your Flutter projects, here are some tips to keep in mind:
1. Add Generated Files to .gitignore
Generated files (like *.g.dart and *.freezed.dart) should typically be committed to version control, but some teams prefer to ignore them and regenerate them in CI/CD. If you choose to ignore them, add this to your .gitignore:
*.g.dart
*.freezed.dart
*.mocks.dart
However, the Flutter community generally recommends committing generated files because it makes builds faster and ensures everyone has the same generated code.
2. Use Watch Mode During Development
When actively developing features that use code generation, run build_runner watch in a separate terminal. This will automatically regenerate code whenever you save changes, keeping your generated files in sync with your source code.
3. Clean Builds When Things Go Wrong
If you encounter issues with generated code, try cleaning and rebuilding:
flutter pub run build_runner clean
flutter pub run build_runner build --delete-conflicting-outputs
The --delete-conflicting-outputs flag is useful when you've made significant changes to your models and need to force regeneration.
4. Combine Packages Wisely
You can use multiple code generation packages together. For example, freezed and json_serializable work great together. Just make sure to include both parts in your file:
part 'model.freezed.dart';
part 'model.g.dart';
5. Understand the Generated Code
While you don't need to modify generated code, it's helpful to understand what it does. Take some time to look at the generated files to see what's being created. This will help you debug issues and understand the limitations of the tools.
Common Pitfalls to Avoid
As with any tool, there are some common mistakes developers make when using code generation:
- Forgetting to run build_runner: If you add annotations but don't run the generator, you'll get compilation errors. Make it a habit to run
build_runnerafter adding or modifying annotated classes. - Circular dependencies: Be careful when models reference each other. Some code generators can handle this, but it requires proper annotation ordering.
- Over-annotating: Not everything needs code generation. Simple classes that don't need JSON serialization or complex equality might be better written manually.
- Ignoring generated file conflicts: If you see conflicts during generation, don't ignore them. Use the
--delete-conflicting-outputsflag or resolve them manually.
Other Useful Code Generation Packages
Beyond json_serializable and freezed, there are other code generation packages worth exploring:
- mockito: Generates mock objects for testing
- injectable: Generates dependency injection code
- retrofit: Generates type-safe HTTP client code
- auto_route: Generates route definitions for navigation
- drift: Generates type-safe database access code
Each of these packages solves specific problems and can significantly reduce the amount of boilerplate code in your Flutter projects.
Conclusion
Code generation is a powerful tool in the Flutter developer's toolkit. By leveraging packages like build_runner, json_serializable, and freezed, you can write less boilerplate code, reduce errors, and focus on building the features that matter.
While there's a learning curve and some setup involved, the benefits are substantial, especially as your projects grow in complexity. Start with JSON serialization for your API models, then explore immutable data classes with freezed as you become more comfortable with the workflow.
Remember, code generation is meant to make your life easier, not more complicated. If you find yourself fighting with the tools or generating more problems than solutions, step back and evaluate whether code generation is the right approach for that particular use case.
Happy coding, and may your generated code be bug-free!