R.I.P. build_runner: A Deep Dive Into Macros in Dart & Flutter
Dart macros are here to change the game for Flutter and Dart developers. If you’ve ever been frustrated by the complexities of using build_runner
for code generation—like slow build times, constant manual regenerations, and synchronization headaches—macros offer a powerful, integrated alternative.
Unlikebuild_runner
, which requires external dependencies and can feel cumbersome, Dart macros enable code generation directly at compile time, eliminating the need for a separate build step. This means faster builds, reduced boilerplate, and more time focused on writing the code that truly matters.
What do we need 🛠️
Macros are a built-in language feature in Dart, meaning all the core functionality is already available. However, there are several helpful extensions that can simplify development—we’ll cover those later.
Since the macros feature hasn’t yet reached the stable channel, we currently need to work within the beta channel or, for those willing to be on the cutting edge, the main channel of Flutter. The Flutter team has announced that the first macro feature (@JsonCodable
) is expected to arrive in the stable channel later this year (2024), with support for custom macro development anticipated in Q1 2025.
Enable Macros
Just run flutter channel beta
followed by a flutter upgrade
(change beta to main if you want to use the latest build from Github). Since macros are a language feature, you also need to change your project’s minimum required Dart version. Macros are supported since Dart 3.5.0-152. You can set this in your pubspec.yaml
under environment
. E.g you can use this configuration:
environment: sdk: '>=3.5.0-152 <4.0.0' flutter: ">=2.0.0"
So the linter does not complain we also need to enable the macros experiment in our analyzer file. In your analysis_option.yaml
add:
enable-experiment: - macros
What are Macros? 📸
Macros are a Dart feature that enable static meta-programming, allowing the language to analyze code and automatically generate additional functionality based on it. While similar tobuild_runner
, macros run instantly during compilation rather than as a separate task, making them much faster.
As mentioned above there is already one macro that works out of the box called @JsonCodable
. Simply add this annotation to a data class, and it will generate everything needed for JSON serialization and deserialization. In most IDEs, you can even jump directly to the generated code. For example, in VSCode, you’ll see a “Go to augmentation” option—click it to view the code created by the macro.
And yes it’s that lightning-fast. Unlike build_runner
combined with JsonSerializable
or Freezed
, macros generate code instantly with no need to run extra commands in the terminal. 🤯
Here are some additional benefits of macros in Dart that make Flutter development easier:
- Code Generation: Macros automatically create repetitive code, like getters, setters, and boilerplate methods, saving you time and reducing errors.
- Goodbye to
build_runner
Tasks: Macros handle code generation at compile-time, eliminating the need forbuild_runner
tasks, which speeds up development. - Compile-Time Optimizations: Macros add optimized code before runtime, which boosts performance without any added runtime overhead.
- Improved Consistency: By automating patterns, macros bring consistency to your codebase, minimizing bugs from human error.
- Custom Annotations: Macros let you create powerful, specific annotations that add functionality—like validation or logging—without cluttering your main code.
Ready to build your own macro? Let’s start with a simple example and then we dive into the details!
The toString Macro
Let’s start with an easy example. We want to create a macro that adds a custom toString
method to a class. The custom toString
should always output “Macros are awesome”.
Macros are created by adding a modifier to a class called macro
. In our case, we want to call our macro Awesome
. I typed out the complete class first and then we will go into detail about what everything does.
import 'dart:async'; import 'package:macros/macros.dart'; final _dartCore = Uri.parse('dart:core'); macro class Awesome implements ClassDeclarationsMacro { const Awesome(); @override Future<void> buildDeclarationsForClass( ClassDeclaration clazz, MemberDeclarationBuilder builder, ) async { final print = await builder.resolveIdentifier(_dartCore, 'print'); builder.declareInType( DeclarationCode.fromParts([ '@override\\n', 'void toString() {\\n', print, '("Macros are awesome");}', ]), ); } }
Now the only thing that’s left is to use our macro. To use a macro you need to annotate the class with the macro.
@Awesome() class Bird { final String name; const Bird(this.name); }
Let’s use our class now and see if it worked:
const bird = Bird('QuickBird'); print(bird.toString()); // Output: Macros are awesome
Wow, it worked 🎉. OK, this looks complicated! We now step through the code and check what every declaration does. We start with the first line (skipping the imports for now)
The Details 🔍
macro class Awesome implements ClassDeclarationsMacro
To create a macro we need to annotate the class with the macro identifier. In our case we want to implement the macro on the class-level, that’s why we need to implement the ClassDeclarationsMacro
. There are also other interfaces to implement a macro on the function-level, constructor-level, or others. We take a look at those later.
Most macros have two important methods that we most of the time have to override:buildDefinitionForClass
provides the mechanism to inspect a class’s structure (its fields, methods, and annotations) at compile-time and allows the macro to inject or modify code based on that class.buildDeclarationsForClass
is used to define or modify the actual implementation of a class (like methods or fields), buildDeclarationsForClass
focuses on generating new declarations that aren’t originally part of the class or its members.
In our example, we just wanted to implement a method that is independent of the rest of the class but technically we could have split it up to build the method definition name, parameters, etc., and later do the print statement within the buildDeclarations method. In that case, we decided to go the easier route for better understanding.
final print = await builder.resolveIdentifier(_dartCore, 'print');
Since we want to print out something to the console we also need the print function from the Dart Standard library. Technically we could write out the print statement as a String but we also need to take care of the import statement. By calling resolveIndentifier
Dart will take care of it. So it’s always advised to use it when you want to use something that’s not directly part of the language or if you want to use some library.
builder.declareInType( DeclarationCode.fromParts([ '@override\\n', 'void toString() {\\n', print, '("Macros is awesome");}', ]), );
As mentioned earlier, there are different types of macros. While some are designed to be applied at the class level, others can be used on functions or constructors. Let’s explore those next.
Types of Macros
ClassDeclarationMacro
This type of macro works on class declarations. It gives you the ability to inspect and modify classes, adding new members (fields, methods, constructors), changing existing ones, or even generate entirely new classes based on the structure of the original class. This makes sense e.g. when:
- Generating
copyWith
,equals
, andhashcode
methods for data classes. - Adding serialization/deserialization logic (like the
@JsonSerializable
example). - Creating factory constructors or helper methods based on class fields.
FunctionDeclarationMacro
FunctionDeclarationMacro
focuses on functions, enabling analysis and transformation of function signatures, parameters, and bodies. Examples of where this is useful include:
- Automatically generating documentation for functions.
- Adding logging or tracing code to functions.
- Creating wrapper functions with modified behavior.
There are even more types of macros, such as VariableDeclarationMacro
, MixinDeclarationMacro
, ConstructionsDeclarationMacro
. For a comprehensive list, refer to Dart’s macros. documentation. Notably, a single macro class can implement multiple macro interfaces, allowing it to operate across several phases.
Generated Code – the augment keyword
The augment
The keyword in Dart is used with macros, allowing developers to introduce code transformations or additions at compile-time. Specifically, it indicates that a macro can modify or “augment” existing code, such as methods or classes. This provides a way to extend or enhance functionality while keeping the source code clean and concise. However, using augment
outside of macros is not recommended. Instead, for general purposes, you should rely on traditional methods like inheritance, mixins, and extensions, unless they are leveraging macros.
Order matters!
You can also add multiple macros to a class and they can also depend on each other. They will be executed bottom ⬆️ top.
@Macro2() @Macro1() class Order {}
In that case, @Macro1 is executed first, and then @Macro2 is executed.
Resource Access in Macros
Some macros may require loading resources, such as files. While this capability is supported, there are safeguards in place because macros run during analysis and are considered untrusted code. Therefore, macros are restricted from accessing resources outside the program’s designated scope.
Other limitations
Some other limitations of macros are:
- All macro constructors must be marked as const.
- Macro classes cannot be generated by other macros.
- All macros must implement at least one of the
macro
interfaces. - Macros cannot be abstract.
This is the current state at the time of writing this article. Things might change in the future.
Conclusion
Macros in Dart promise to address some of the most tedious, repetitive tasks in Flutter and Dart development. With macros, we can finally move beyond the burdens of build_runner
and JSON serialization/deserialization. This opens up exciting possibilities for implementing functionalities that streamline user experiences by concealing complexity, making advanced features like navigation, storage, and modularization more accessible.
However, macros also come with potential pitfalls. They can obscure functionality and lead to hidden behaviors that developers might not anticipate. Building macros is complex work that requires an understanding of various language elements—not the usual development task. Like withbuild_runner
plugins, writing macros often involves detecting specific class elements and generating code, necessitating extensive string manipulation.
In short, macros hold promise for enhancing productivity but demand careful use to balance power with clarity.
Thanks for reading!