Static Metaprogramming in Dart and Flutter: macro_prototype overview

24 July 2022[updated]

7 min read

Mobile development

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.

Macro and Meta-programming are still being discussed!

As I am writing this article, Macro and Meta-Programming are still under discussion. The final version may be completely different from the current implementation.


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

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:

  1. Type Macros: introduce entirely new classes to the application (ClassTypeMacro, FieldTypeMacro, or MethodTypeMacro)
  2. Declaration Macros: introduce new public declarations to classes, as well as the current library, but not new types (ClassDeclarationMacro, FieldDeclarationMacro, or MethodDeclarationMacro)
  3. Definition Macros: allowed to implement existing declarations. No new declarations can be added in this phase (ClassDefinitionMacro, FieldDefinitionMacro, or MethodDefinitionMacro)

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();

  
  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:

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();

  
  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';


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
union.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';


class Union {
  final int part1;
  final String part2;
  const Union({required this.part1, required this.part2});
}
union.declarations.dart
// 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';


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});
}
union.types.dart
// 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';


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.