Write New Macro topic
Macro Kit Documentation
A comprehensive guide to creating and using macros in Dart with macro_kit.
Table of Contents
- Introduction
- Creating a Macro Generator
- Understanding Macro Capabilities
- Working with Macro Data
- Code Generation
Introduction
Macro Kit enables you to generate code based on class annotations. There are two types of macros:
- Regular macros - Applied to Dart code (classes, methods, etc.)
- Asset macros - Applied to asset directories or files
This guide focuses on regular macros that generate code from annotated classes.
Creating a Macro Generator
Step 1: Define Your Macro Capability
First, determine what information your macro needs from the annotated class. This is done through
MacroCapability, which specifies what data to collect during analysis.
const dataClassMacroCapability = MacroCapability(
classFields: true,
filterClassInstanceFields: true,
classConstructors: true,
);
Step 2: Create Your Macro Class
Create a class that extends MacroGenerator and set the default capability:
class DataClassMacro extends MacroGenerator {
const DataClassMacro({
super.capability = dataClassMacroCapability,
this.fromJson,
this.toJson,
});
final bool? fromJson;
final bool? toJson;
}
Important: Your macro class name must end with the suffix Macro.
Step 3: Implement the Initialize Method
Create a static initialize method that maps configuration properties to your macro instance:
class DataClassMacro extends MacroGenerator {
const DataClassMacro({
super.capability = dataClassMacroCapability,
this.fromJson,
this.toJson,
});
final bool? fromJson;
final bool? toJson;
static DataClassMacro initialize(MacroConfig config) {
final key = config.key;
final props = Map.fromEntries(
key.properties.map((e) => MapEntry(e.name, e))
);
return DataClassMacro(
capability: config.capability,
fromJson: props['fromJson']?.asBoolConstantValue(),
toJson: props['toJson']?.asBoolConstantValue(),
);
}
}
Helper methods for property mapping:
asBoolConstantValue()- Extract boolean valuesasIntConstantValue()- Extract integer valuesasStringConstantValue()- Extract string valuesasDoubleConstantValue()- Extract double valuesasTypeValue()- Extract type references
Step 4: Usage
Users can now annotate classes with your macro:
@Macro(DataClassMacro())
class User with UserData {
final String name;
final int age;
}
Recommended: Create a constant for easier usage:
const dataClassMacro = Macro(DataClassMacro());
@dataClassMacro
class User with UserData {
final String name;
final int age;
}
Step 5: Implement Code Generation
Implement the required methods in your MacroGenerator:
class DataClassMacro extends MacroGenerator {
// ... previous code ...
@override
String get suffixName => 'Data';
@override
GeneratedType get generatedType => GeneratedType.mixin;
@override
void init(MacroState state) {
// Optional: Perform any initialization work
}
@override
void onClassFields(MacroState state, List<MacroProperty> fields) {
// Process class fields and store in state if needed
state.setData('fields', fields);
}
@override
void onGenerate(MacroState state) {
final fields = state.getData<List<MacroProperty>>('fields');
final suffix = state.suffixName; // use the provided suffix to support combing code
final code = StringBuffer();
code.writeln('mixin ${state.className}$suffix {');
// Generate your code here
code.writeln('}');
state.reportGeneratedCode(code.toString());
}
}
Key properties and methods:
suffixName- The suffix appended to the class name (e.g.,Data→UserData).generatedType- The type of code being generated (mixin, class, abstract class, etc.).init()- Optional setup method called before generation.onClassTypeParameter()- Called when the target class has type parameters.onClassFields()- Called when class fields are collected.onClassConstructors()- Called when constructors are collected.onClassMethods()- Called when methods are collected.onClassSubTypes()- Called with all subtypes of the target class in the library.onTopLevelFunctionTypeParameter()- Called when the target function has type parameters.onTopLevelFunction()- Called when the target function is a top level function.onAsset()- Called when a monitored asset file changes in configured directories.onGenerate()- Final method where you generate and report code.
Understanding Macro Capabilities
MacroCapability controls what information gets collected from annotated classes. This improves
performance by only analyzing what's needed.
See in details: Macro Capability
Working with Macro Data
MacroProperty
MacroProperty represents a field, parameter, return type, or type reference with comprehensive
metadata.
Basic Properties
class MacroProperty {
final String name; // Property name
final String importPrefix; // Import prefix (empty if none)
final String type; // Dart type as string (e.g., 'String', 'List<int>')
final TypeInfo typeInfo; // Type category (class, enum, primitive, etc.)
final MacroModifier modifier; // Modifiers (final, late, nullable, etc.)
}
Type Information
The typeInfo field indicates the category of type:
TypeInfo.int,TypeInfo.string,TypeInfo.boolean- PrimitivesTypeInfo.list,TypeInfo.set,TypeInfo.map- CollectionsTypeInfo.clazz- Custom classesTypeInfo.enumData- EnumsTypeInfo.function- Function typesTypeInfo.generic- Generic type parametersTypeInfo.dynamic- Dynamic type
Working with Types
// Get fully qualified type with import prefix
String dartType = property.getDartType('_i.'); // e.g., '_i.String'
// Check nullability
bool nullable = property.isNullable;
// Convert to nullable/non-nullable
MacroProperty nullableVersion = property.toNullability(intoNullable: true);
// Check if static
bool isStatic = property.isStatic;
Type Arguments (Generics)
// For List<String>
final typeArgs = property.typeArguments; // [MacroProperty(name: '', type: 'String')]
// For Map<String, int>
final keyType = property.typeArguments?.firstOrNull; // String
final valueType = property.typeArguments?.elementAtOrNull(1); // int
Constant Values
For compile-time constants:
// Extract constant values
bool? boolValue = property.asBoolConstantValue();
String? stringValue = property.asStringConstantValue();
int? intValue = property.asIntConstantValue();
double? doubleValue = property.asDoubleConstantValue();
// Convert to Dart literal
String? literal = property.constantValueToDartLiteralIfNeeded;
Macro Keys
// Access macro keys
List<MacroKey>? keys = property.keys;
// Cache and retrieve key values
String name = property.cacheFirstKeyInto<String>(
keyName: 'name',
convertFn: (key) => key.name,
defaultValue: property.name,
);
Code Generation
The Generation Lifecycle
When a macro is applied to a class, the following lifecycle occurs:
- Initialization -
init()is called - Data Collection - Based on capability:
onClassFields()- IfclassFields: trueonClassConstructors()- IfclassConstructors: trueonClassMethods()- IfclassMethods: true
- Code Generation -
onGenerate()is called - Code Reporting -
state.reportGeneratedCode()emits the generated code
Using MacroState
MacroState is your workspace for storing temporary data and generating code:
Example workflow:
@override
void onClassFields(MacroState state, List<MacroProperty> fields) {
// Store fields for later use
state.setData('fields', fields);
// Process and store additional data
final jsonFields = fields.where((f) => !f.modifier.isStatic).toList();
state.setData('jsonFields', jsonFields);
}
@override
void onGenerate(MacroState state) {
final fields = state.getData<List<MacroProperty>>('fields')!;
final jsonFields = state.getData<List<MacroProperty>>('jsonFields')!;
final code = generateCode(state.className, fields, jsonFields);
state.reportGeneratedCode(code);
}
Code Generation & Multi-Macro Combination
Multiple macros may target the same class. The framework merges their capabilities, collected data, and generated output according to the rules below. This ensures macros—whether from your package or from different authors—can interact safely without overwriting each other’s output.
🔹 How Capabilities Combine Across Macros
When a class has multiple annotations:
@FirstMacro()
@SecondMacro()
class User {}
Each macro declares its own MacroCapability.
Before lifecycle execution begins, the framework merges all capabilities and still receives only the parts it declared in its own capability.
🔹 Code Generation Modes
Macros may generate:
- A full wrapper class/mixin
- Only method bodies
- Both
- Nothing (analysis-only macro)
To allow combining output from multiple macros, macros must respect the generation mode.
#### MacroState.isCombiningGenerator
When multiple macros are applied to the same type, the framework enters combining mode, meaning:
- You must not output a full class/mixin wrapper.
- You must only output members (methods, getters, fields, utility functions).
Example:
class DataClassMacro extends MacroGenerator {
Future<void> onGenerate(MacroState state) async {
if (state.isCombiningGenerator) {
// Only generate members like:
}
}
}
🔹 canBeCombined – Opt-in/Out of Multi-Macro Combination
When reporting generated code:
class DataClassMacro extends MacroGenerator {
Future<void> onGenerate(MacroState state) async {
// your code here
state.reportGenerated(generatedCode, canBeCombined: true);
}
}
If your macro can combine with others:
- Set
canBeCombined: true - Generate only members when
isCombiningGenerator == true - Generate wrappers when
isCombiningGenerator == false
If your macro cannot combine:
Set: canBeCombined to false
This ensures:
- Your macro runs alone
- No other macro is allowed to generate combined output
- The file will contain a complete wrapper
This is required for macros that:
- Produce a new type that must remain unique
- Perform structural rewrites
- Emit code where combining would compromise correctness
- Require positional ordering of members
Generated Type Resolution
generatedType plays a key role in deciding whether macros’ outputs are merged or emitted
separately.
Rules
-
Same
generatedType+ all macros allow combining (canBeCombined: true) → Merge- If two (or more) macros specify the same
generatedTypeand each reportedcanBeCombined: true, the framework will aggregate their generated members and place them into a single generated type with that name (class, mixin, or extension as appropriate). - This is how multiple macros can collaboratively augment the same generated artifact (for
example, the same
mixin UserData).
- If two (or more) macros specify the same
-
Different
generatedType+ all macros allow combining → Emit separate generated types- If macros target different
generatedTypelike mixin vs class but both permit combining, the build system does not merge them because they target different artifacts. Instead, each macro’s output becomes its own generated type. - This covers cases where one macro wants a mixin and another wants a concrete class; both may be placed in the same generated file, but they are kept as separate types and are not merged.
- If macros target different
-
If any macro sets
canBeCombined: false→ Exclusive ownership- A macro that reports
canBeCombined: falsebecomes the exclusive generator for the generated types it creates. Other macros that would otherwise combine are prevented from merging into that generated type;
- A macro that reports
Building Type References
When generating code, use getDartType() to build proper type references:
String buildFieldCode(MacroProperty field, String dartCorePrefix) {
final type = field.getDartType(dartCorePrefix);
final name = field.name;
if (field.modifier.isFinal) {
return 'final $type $name;';
} else {
return '$type $name;';
}
}
Working with Generics
void generateGenericClass(MacroClassDeclaration declaration) {
final typeParams = declaration.classTypeParameters ?? [];
// Get type parameter list: <T, E>
final typeParamStr = MacroProperty.getClassTypeParameter(typeParams);
// Get with bounds: <T extends Object, E extends String>
final typeParamWithBound = MacroProperty.getClassTypeParameterWithBound(typeParams);
final code = '''
class ${declaration.className}Data$typeParamWithBound {
// Generated code here
}
''';
}
Handling Field Initializers
When a constructor parameter initializes a field:
void processConstructor(MacroClassConstructor constructor) {
for (final param in constructor.params) {
// Get the actual field being initialized
final field = param.getTopFieldInitializer();
if (field != null) {
print('Parameter ${param.name} initializes field ${field.name}');
print('Field type: ${field.type}');
}
}
}
Converting Constants to Literals
When you need to output constant values as Dart code:
String classLiteral = MacroProperty.toLiteralValue(constantValue);
// Direct conversion for simple type
String? dartLiteral = property.constantValueToDartLiteralIfNeeded;
Performance Considerations
- Request only what you need - Use specific capability filters to minimize analysis
- Avoid
inspectFieldInitializer- This is an expensive operation; use only when necessary - Cache computed values - Use
MacroState.set()to avoid recomputing - Use
cacheFirstKeyInto()- Cache key lookups for repeated access
Error Handling
Always validate your inputs:
@override
void onGenerate(MacroState state) {
final fields = state.getData<List<MacroProperty>>('fields');
if (fields == null || fields.isEmpty) {
// No fields to process, generate empty implementation or skip
state.reportGeneratedCode('');
return;
}
// Continue with generation
}
Classes
- Macro Get started Installation Models Data Class Macro Asset Path Macro Global Configuration Write New Macro Capability
- Macro used to attach metadata to a Dart declaration.