Enhance Your Macros: Pass Type to Macro Classes
In the previous article, I introduced a step-by-step guide to making macro classes.
https://chooyan.hashnode.dev/make-your-macros-step-by-step-guide-for-flutter-macros
Yet, the macro lacks some important functionality for MaterialPageRoute
. That is <T>
.
MaterialPageRoute
, or its superclass Route
, gets <T>
as their type parameter to specify the return type of .push()
method like below.
final int? result = await Navigator.of(context).push(
MaterialPageRoute<int>((context) => const NextPage()),
);
Doing so, NextPage
can return int
value using the argument of .pop()
method like below.
Navigator.of(context).pop(42); // return 42 to the previous page
As our RouteMacro
, made in the previous article, doesn't have the functionality to specify <T>
now, let's add the feature in this article investigating how to pass types to macro classes.
Options to pass types to macro classes
Considering how to pass type to macro classes, 2 options come to our mind at first.
/// pass [int] as type parameter
@RouteMacro<int>()
class NextPage extends StatelessWidget {}
/// pass [int] as an argument with type [Type]
@RouteMacro(int)
class NextPage extends StatelessWidget {}
But unfortunately, neither of them doesn't work, at least with the current version of Channel master, 3.24.0-1.0.pre.69
. I'll discuss the reasons one by one.
Strategy 1: Type parameter
According to the specification document's limitations/requirements
section, "Macro classes cannot contain generic type parameters".
Macro classes cannot contain generic type parameters.
- It is possible that in the future we could allow some restricted form of generic type parameters for macro classes, but it gets tricky because the types in the user code instantiating the macro are not necessarily present in the macros own transitive imports.
Besides this issue, knowing "where the type comes from" for macro classes seems to be a difficult concern. That type potentially has a complex hierarchy, is made by another macro class, or has a complicated definition using records or generics.
Anyways, we can't take this option at least right now, so let's discuss the next one.
Strategy 2: Argument
The previous specification document says we can pass Type
as an argument of the macro's constructor, and the compiler implicitly converts it to TypeAnnotation
object to be available with macro-related APIs.
- If the parameter type is
TypeAnnotation
then a literal type must be passed, and it will be converted to a correspondingTypeAnnotation
instance.
See the Macro Arguments
section linked below for more details.
TypeAnnotation
preserves not only the name of the type but also Code
, Identifier
, and more stuff for code generation and code introspection operations, meaning we can generate MaterialPageRoute<int>
by receiving this as an argument.
Unfortunately, however, this feature has not been implemented yet in the current version according to the comment on GitHub issue.
The intention is to pass these as regular arguments, and have them coerced into a TypeAnnotation implicitly, but that isn't implemented yet.
Thus, we have to wait until this feature is introduced and need another option.
Strategy 3: Implements interface
Here comes the third option; making NextPage
implement some interface class with a generic type <T>
.
/// Implement [RouteInterface<T>] with the type <int>
/// @RouteMacro()
class NextPage extends StatelessWidget implements RouteInterface<int> {}
This is tricky but we can workaround with this trick even with the current version.
As the definition of the augmented class's interfaces can be retrieved by ClassDeclaration
object, which is given as an argument of buildDeclarationsForClass()
method, with clazz.interfaces
, we can refer to the interface RouteInterface
and its type argument <int>
like below.
macro class RouteMacro implements ClassDeclarationsMacro {
const RouteMacro();
@override
Future<void> buildDeclarationsForClass(
ClassDeclaration clazz,
MemberDeclarationBuilder builder,
) async {
/// retrieve interfaces
final Iterable<NamedTypeAnnotation> interfaces = clazz.interfaces;
/// retrieve type argument
final TypeArgument typeArgument = interfaces.first.typeArguments.first;
);
}
}
Note that we omit checking the case that users don't implement RouteInterface
for the simplicity of the code above. We may need to check the relevant interface like below.
final typeArgument = interfaces.firstWhereOrNull((interface) {
return interface.identifier.name == 'RouteInterface'
&& interface.typeArguments.isNotEmpty;
});
if (typeArgument == null) {
// generate code with MaterialPageRoute
} else {
// generate code with MaterialPageRoute<T>
}
Generate code with return type <T>
Once we deal with retrieving typeArgument
whatever strategies, what we do next is just apply it to our generated code. Our builder.declareInType()
method introduced in the previous article is updated below.
builder.declareInType(DeclarationCode.fromParts([
'''
static Route<''',
typeArgument.code,
'''> route() {
return MaterialPageRoute<''',
typeArgument.code,
'''>(
builder: (context) => const ''',
widget,
'''(),
);
}
''',
Now, our macro will generate the augmentation code below.
augment class NextPage {
static Route<prefix0.int> route() {
return MaterialPageRoute<prefix0.int>(
builder: (context) => const prefix1.NextPage(),
);
}
}
We can see int
, with prefix prefix0
, is now successfully in our generated code!
And the return type of push()
is now also Future<int?>
.
Conclusion
I believe a lot of macros need type
for their generating code, so I also believe understanding how users can pass type
to our macros will be crucial.
Though some features are still in development, it's valuable to know other workaround options and also the limitations/restrictions of macros.
Make sure to read the specification document and issues discussing "why" if you need detailed and precise information about macros.
https://github.com/dart-lang/language/blob/main/working/macros/feature-specification.md
https://github.com/dart-lang/language/issues?q=is%3Aissue+macros