Creating your own macro instead of code generation in Dart 3.5

Alexey Inkin
7 min readJun 14, 2024

--

Dart 3.5 has a major new feature: macros. Think of it as code generation that happens at compile time entirely in memory and does not require temporary files. But it brings a lot more than that.

This is currently in beta, and the Dart team is careful not to share too much while it’s unstable. Their public roadmap is the following:

  • Right now they have a single @JsonCodable macro that replaces json_serializable package and dramatically reduces its overhead. With it, we can familiarize ourselves with the feature.
  • This single macro will become stable sometime in 2024.
  • Writing your own macros will be available in early 2025.

But what if you try to create your own macro right now? Their wording made me think there would be some roadblock like a whitelist of enabled macros, but there’s none!

I could create and publish my own macro now, and it works. We don’t need to wait for 2025, and there are no limits to the experimentation, except that things can break and we shouldn’t use it in production.

So let’s create a few of our own macros now! We will:

  1. Reproduce a hello-world macro from the Dart team.
  2. Write a hello-world macro of our own.
  3. Dive deep into my macro for creating a command-line argument parser.

Set up the experiment

Dart 3.5

Follow the official instructions to switch to the beta version of Dart 3.5: https://dart.dev/language/macros#set-up-the-experiment

I just downloaded a ZIP and extracted it to a separate path.

VSCode

You need the recent stable version of the Dart plugin for VSCode to view the code generated by macros.

pubspec.yaml

To use the example macro, you need at least Dart 3.5.0-154. Create pubspec.yaml like this:

name: macro_client
environment:
sdk: ^3.5.0-154

dependencies:
json: ^0.20.2

analysis_options.yaml

As you are writing the code, the analyzer will be complaining unless you tell it that you are experimenting with this feature. Create the following analysis_options.yaml:

analyzer:
enable-experiment:
- macros

Write the code

Use the example that the Dart team provides:

import 'package:json/json.dart';

@JsonCodable() // Macro annotation.
class User {
final int? age;
final String name;
final String username;
}

void main() {
// Given some arbitrary JSON:
final userJson = {
'age': 5,
'name': 'Roger',
'username': 'roger1337',
};

// Use the generated members:
final user = User.fromJson(userJson);
print(user);
print(user.toJson());
}

Run it in terminal with the experimental flag:

dart run --enable-experiment=macros lib/main.dart

Or configure VSCode for the experiment. Open settings.json:

And change it like this:

Now this thing just works and prints this:

Instance of 'User'
{age: 5, name: Roger, username: roger1337}

Note that the class is only 6 lines long:

@JsonCodable()
class User {
final int? age;
final String name;
final String username;
}

While the equivalent class with json_serializable would be 16 lines long:

@JsonSerializable()
class User {
const Commit({
required this.age,
required this.name,
required this.username,
});

final int? age;
final String name;
final String username;

factory User.fromJson(Map<String, dynamic> map) => _$UserFromJson(map);

Map<String, dynamic> toJson() => _$UserToJson(this);
}

View the generated code

VSCode shows you the link “Go to Augmentation” under the use of @JsonCodable macro. When you click it, the generated code shows:

Unlike the old code generation, it is not in a real file but in memory. You can’t edit it. When you change anything in the original main.dart, the generated code updates, so you don’t need to run the generator separately.

If you can’t use VSCode, check out my tool to view the same code.

How it works: augmentation

Now what’s going on here? This code uses the new Dart feature called augmentation. It is the ability to change a class or function by adding members or replacing bodies outside of the original block.

This feature is independent of macros, and its simplest use is this:

class Cat {
final String name; // "Uninitialized" error unless we have a constructor.
}

augment class Cat {
Cat(this.name); // Resolves the error.
}

This augmentation can be in a separate file from the original class. What a macro really does is it generates such a file with the augmentation. The actual practical difference from the old code generation is that this is now in memory and not in a .g.dart physical file.

So if the Dart team upgrades their json_serializable package to use augmentation, your code could potentially be just as short as with a macro as a constructor can be generated, and you will not need the boilerplate forwarders toJson and fromJson.

Praise the real undervalued killer feature, which is augmentation. Macros, although much harder to implement in the compiler, come secondary here.

Making your own hello-world macro

Create hello.dart with this code for the macro:

import 'dart:async';

import 'package:macros/macros.dart';

final _dartCore = Uri.parse('dart:core');

macro class Hello implements ClassDeclarationsMacro {
const Hello();

@override
Future<void> buildDeclarationsForClass(
ClassDeclaration clazz,
MemberDeclarationBuilder builder,
) async {
final fields = await builder.fieldsOf(clazz);
final fieldsString = fields.map((f) => f.identifier.name).join(', ');

final print = await builder.resolveIdentifier(_dartCore, 'print');

builder.declareInType(
DeclarationCode.fromParts([
'void hello() {',
print,
'("Hello! I am ${clazz.identifier.name}. I have $fieldsString.");}',
]),
);
}
}

This macro creates a method named hello on the class you apply it to. The method prints the name of the class and the fields it has.

The macro is implemented as a class with macro modifier. It implements ClassDeclarationsMacro, which tells the compiler that it can be applied to classes and will run when it’s time to update declarations. There are many interfaces your macro can implement to be applied to various code entities at various phases of code generation. We will talk about that when we get to my command-line argument parsing macro.

This interface has a method buildDeclarationsForClass, which we need to implement, and it will be called when appropriate. It’s passed:

  1. The class declaration to access the information about the class it’s applied to.
  2. The builder object with methods to inspect the given class and add code to it.

We use the builder to get the fields of the class.

The actual code generation is easy. The builder has declareInType method to add any code to the class it’s augmenting. The simplest code can just be a string, but there’s a tricky part because you can’t just write a call to the print function as a string.

If you look at the example augmentation from JsonCodable macro we saw earlier, you will find that dart:core is imported with a prefix:

import 'dart:core' as prefix0;

It’s done automatically to make sure your code does not collide with any of the core stuff like print. The prefix is dynamic, and you can’t know it in advance, so you can’t just write print(something) in the code you generate. That’s why we resolve the identifier print from the core library and then build the generated code from parts:

final print = await builder.resolveIdentifier(_dartCore, 'print');

builder.declareInType(
DeclarationCode.fromParts([
'void hello() {',
print,
'("Hello! I am ${clazz.identifier.name}. I have $fieldsString.");}',
]),
);

Parts can be a mixture of strings and identifier references that are glued together in the end. In that process, all identifiers are prepended with any necessary prefixes.

Write this code that uses the new macro:

import 'hello.dart';

@Hello()
class User {
const User({
required this.age,
required this.name,
required this.username,
});

final int? age;
final String name;
final String username;
}

void main() {
final user = User(age: 5, name: 'Roger', username: 'roger1337');
user.hello();
}

Click “Go to Augmentation” and see what’s generated:

Note that the print function was prepended with prefix0, the identifier for the core library import. This happens automatically when you pass an identifier among code parts. Also, it’s this action that makes the generator import dart:core in the first place. Note how we didn’t add this import explicitly.

Run the code:

dart run --enable-experiment=macros hello_client.dart

And you can see it prints:

Hello! I am User. I have age, name, username.

The real useful macros

There are two real-world macros you can follow to learn further:

JsonCodable

It’s the pilot macro that the Dart team released for us to learn this feature. I highly suggest reading thorough its code. This is where I learned almost everything.

Args

This is the macro I created.

If you make apps that run in a terminal, you are familiar with command-line arguments and their parsing. Normally, the standard args package is used for that:

import 'package:args/args.dart';

void main(List<String> argv) {
final parser = ArgParser();
parser.addOption('name');
final results = parser.parse(argv);
print('Hello, ' + results.option('name'));
}

You can run

dart run main.dart --name=Alexey

and see

Hello, Alexey

The problem is, that gets messy when you have many command-line options. You can lose track of them, and there’s no compile-time guarantee that an option is there and is of a specific type. You can’t easily rename an option because this code operates with their names as string literals.

So my Args macro creates a parser from any data class where you define what options you want, and you have compile-time type safety when reading them:

import 'package:args_macro/args_macro.dart';

@Args()
class HelloArgs {
String name;
int count = 1;
}

void main(List<String> argv) {
final parser = HelloArgsParser(); // Generated class.
final HelloArgs args = parser.parse(argv);

for (int n = 0; n < args.count; n++)
print('Hello, ${args.name}!');
}

I will dive deep into making of this macro in the second part of this article (UPD: it’s here). Follow to read it when it’s out:

--

--

Alexey Inkin

Google Developer Expert in Flutter. PHP, SQL, TS, Java, C++, professionally since 2003. Open for consulting & dev with my team. Telegram channel: @ainkin_com