← Back to Articles

Flutter Code Generation: Reducing Boilerplate with build_runner and Friends

Flutter Code Generation: Reducing Boilerplate with build_runner and Friends

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.

Code Generation Flow Your Dart Code with annotations build_runner analyzes code Generated Code .g.dart files

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_annotation and use the @JsonSerializable() annotation
  • We include a part directive pointing to the generated file (user.g.dart)
  • We define factory methods (fromJson and toJson) 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
}
JSON Serialization Process JSON String {"name": "John", "email": "..."} fromJson() User Object User(name: "John", email: "...") toJson() JSON Map Map<String, dynamic> Generated Code Handles conversion

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 == and hashCode
  • 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.

Union Types with freezed WeatherState Loading Loaded temp, condition Error message when() Pattern Matching Handles all states exhaustively

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_runner after 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-outputs flag 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!