Macro in Dart have been announced at Flutter Engage in March 2021. The Dart team started exploring static metaprogramming for dart.
Metaprogramming is "Code that produces other code". This feature could make the language a lot more powerful, allowing the developer to define new syntax in the language itself.
In fact, on January 7 2021 the macro_prototype repository has been created on Github.
This project represents a possible macro system based on build_runner. In this article, we are going to explore this macro prototype and learn how macro will (possibility) be working in dart.
As of February 2024 an official prototype is available in dart.
You can read more about how it works and why it matters in Macros, Static Metaprogramming, and Primary Constructors in Dart and Flutter
Static Metaprogramming
The definition of static metaprogramming from the dart team is as follows:
Metaprogramming refers to code that operates on other code as if it were data. It can take code in as parameters, reflect over it, inspect it, create it, modify it, and return it.
Static metaprogramming means doing that work at compile-time, and typically modifying or adding to the program based on that work.
Static Metaprogramming would allow developers to enhance the features of a class, a method, or a type by adding any kind of code at static time.
The dart team introduced metaprogramming at Flutter Engage. They presented the benefits and rationale for introducing static metaprogramming in dart:
https://youtu.be/yll3SNXvQCw?t=3622
Static metaprogramming introduced officially at Flutter Engage, March 2021.
Since then, a long discussion started inside the dart community to define the specs for metaprogramming in dart. Static Metaprogramming is still being discussed by the community on Github.
macro_prototype
The dart team also started working on a Macro prototype to test some possible features for the final macro implementation in dart. They used build_runner
to implement the build steps of a Macro definition.
We are going to learn how this prototype works in this article. If you are interested or you would like to propose any new (interesting) idea, leave a comment on the Static Metaprogramming issue on Github.
Installation
Since the macro_prototype
is hosted on Github, you need to import the package directly from there. Add the following to your pubspec.yaml
file:
dev_dependencies:
macro_builder:
git:
url: git://github.com/jakemac53/macro_prototype.git
ref: main
build_runner: ^2.1.0
There is more 🤩
Timeless coding principles, practices, and tools that make a difference, regardless of your language or framework, delivered in your inbox every week.
Macro definition
In dart, a macro consists of a class that can inspect the code of another class (or method, or type) and insert new code inside it.
In order to setup the build tool to generate the code from the macro, we need to create a build.yaml
file in the root of the project. The build.yaml
file needs to have the following content:
targets:
$default:
builders:
# Change 'prototype' with the name of your project!
prototype:prototype:
enabled: true
builders:
# Change 'prototype' with the name of your project!
prototype:
# Update path below to match the location of your builders.dart file
import: "lib/builders.dart"
builder_factories:
["typesBuilder", "declarationsBuilder", "definitionsBuilder"]
build_extensions:
{ ".gen.dart": [".types.dart.", ".declarations.dart.", ".dart"] }
auto_apply: dependents
build_to: source
Then, inside the lib
folder, create a builders.dart
file with the following content:
import 'package:macro_builder/builder.dart';
import 'package:build/build.dart';
Builder typesBuilder(_) => TypesMacroBuilder([]);
Builder declarationsBuilder(_) => DeclarationsMacroBuilder([]);
Builder definitionsBuilder(_) => DefinitionsMacroBuilder([]);
The above setup allows the build tool to understand which classes to generate and which macros have been defined in the project.
Define the macro
There are multiple ways to define a macro using the macro_prototype
. I suggest you to read the documentation about the macro_prototype package to understand more in the details how macro works in dart.
In summary, we have three distinct phases in the macro generation process:
- Type Macros: introduce entirely new classes to the application (
ClassTypeMacro
,FieldTypeMacro
, orMethodTypeMacro
) - Declaration Macros: introduce new public declarations to classes, as well as the current library, but not new types (
ClassDeclarationMacro
,FieldDeclarationMacro
, orMethodDeclarationMacro
) - Definition Macros: allowed to implement existing declarations. No new declarations can be added in this phase (
ClassDefinitionMacro
,FieldDefinitionMacro
, orMethodDefinitionMacro
)
Defining a macro consists in creating a new class which implements one or more of the macro classes presented above:
import 'package:macro_builder/definition.dart';
const exampleClass = _ExampleClass();
class _ExampleClass implements ClassDeclarationMacro {
const _ExampleClass();
@override
void visitClassDeclaration(ClassDeclaration declaration, ClassDeclarationBuilder builder) {
// Implementation!
}
}
Create the Macro class
The code generation is managed inside the visitClassDeclaration
function. Based on the specific macro phase you implement, you will have access to different inputs used to inspect the source code of the class in which the macro is applied.
In the example, we have access to two inputs:
- ClassDeclaration: it allows to inspect methods and attributes from the source class
- ClassDeclarationBuilder: it allows to output code which will be added to the annotated class
All you need to do is call builder.addToClass
to insert any definition inside the annotated class. Here below I report an example which creates a copyWith
method inside the class:
import 'package:macro_builder/definition.dart';
const copyWith = _CopyWith();
class _CopyWith implements ClassDeclarationMacro {
const _CopyWith();
@override
void visitClassDeclaration(
ClassDeclaration declaration,
ClassDeclarationBuilder builder,
) {
Code code = Fragment.fromParts([
Fragment('${declaration.name} copyWith({'),
...declaration.fields.map((e) => Fragment('${e.type.name}? ${e.name},')),
Fragment('}) => ${declaration.name}('),
...declaration.fields
.map((e) => Fragment('${e.name}: ${e.name} ?? this.${e.name},')),
Fragment(');')
]);
builder.addToClass(Declaration('$code'));
}
}
Check out all the other examples inside the repository.
Including the macro in the build
The last step to actually activate the macro is to add its definition to the builders.dart
file. Pay attention to include the macro in the correct Builder
based on the type you implemented:
import 'package:macro_builder/builder.dart';
import 'package:build/build.dart';
import 'macros/copy_with.dart';
Builder typesBuilder(_) => TypesMacroBuilder([]);
Builder declarationsBuilder(_) => DeclarationsMacroBuilder([copyWith]);
Builder definitionsBuilder(_) => DefinitionsMacroBuilder([]);
Class annotation
The dart team decided (at least for this prototype) to use annotations to apply a macro to a class. You are required to create a files using the *.gen.dart
extension. The build step will inspect all the file and apply the macro only to the files with the gen
suffix.
Let's create a union.gen.dart
file and add the following code to it:
import 'macros/copy_with.dart';
@copyWith
class Union {
final int part1;
final String part2;
const Union({required this.part1, required this.part2});
}
Then launch the build command to generate the code:
flutter pub run build_runner build
The package will generate three file:
union.dart
: the main file to use in your project.union.declarations.dart
union.types.dart
// GENERATED FILE - DO NOT EDIT
//
// This file was generated by applying the following macros to the
// `example/union.gen.dart` file:
//
// - Instance of '_ToUnion'
//
// To make changes you should edit the `example/union.gen.dart` file;
import 'macros/copy_with.dart';
@copyWith
class Union {
final int part1;
final String part2;
const Union({required this.part1, required this.part2});
}
// GENERATED FILE - DO NOT EDIT
//
// This file was generated by applying the following macros to the
// `example/union.types.dart` file:
//
// - Instance of '_ExampleClass'
// - Instance of '_Adt'
// - Instance of '_CopyWith'
//
// To make changes you should edit the `example/union.gen.dart` file;
import 'macros/copy_with.dart';
@copyWith
class Union {
Union copyWith({
int? part1,
String? part2,
}) =>
Union(
part1: part1 ?? this.part1,
part2: part2 ?? this.part2,
);
final int part1;
final String part2;
const Union({required this.part1, required this.part2});
}
// GENERATED FILE - DO NOT EDIT
//
// This file was generated by applying the following macros to the
// `example/union.declarations.dart` file:
//
//
// To make changes you should edit the `example/union.gen.dart` file;
import 'macros/copy_with.dart';
@copyWith
class Union {
final int part1;
final String part2;
Union copyWith({int? part1, String? part2}) =>
Union(part1: part1 ?? this.part1, part2: part2 ?? this.part2);
const Union({required this.part1, required this.part2});
}
Notice how the macro added a copyWith
method to the generated class. That's the power of macros!
You now know a little bit more about macros and static metaprogramming in dart. Macros could be a major step ahead for dart and flutter, and I cannot wait to see what's coming!
If you learned something new from the article, follow me on Twitter at @SandroMaglione and subscribe to my newsletter here below for more updates and cool news and tutorials 👇
Thanks for reading.