This article is part of the series Code generation in Flutter. Throughout this series, we will provide more information on analyzing, generating, and building your code using code generation: a convenient method for reducing boilerplate code and making work easier.
Code generation in Flutter: analyzing ( 👈 this one)
Code generation in Flutter: generating - TBD
Code generation in Flutter: building - TBD
Today, we will cover code analysis that can later be used to generate new code. Specifically, it will focus on an analysis of classes with annotations. In Dart, we use the analyzer
package for static analysis of Dart code. This package also runs behind the Dart Analysis Server you use in your IDE or the dart analyze
command to show error, warning, and info problems in your code configured with Dart lints. We will focus on the analyzed representation of the Dart code itself.
The analyzer package provides the AST (Abstract Syntax Tree) model that describes the syntactic structure. We can use this model to retrieve some data that is later used for the generation, but we will instead focus on the element model, which describes the semantic structure of Dart code. Note that elements cover only declarations.
Below is an example of using @AutoMappr
annotation from the auto_mappr
package that sets a mapping for UserDto
into User
and renames the source field age
to the target field myAge
. The annotation has one positional argument, mappers
, which is of type List<MapType>
. The object MapType<Source, Target>
represents a mapping between the source and the target types. It has a fields
argument of type List<Field>
. The object Field
can have arguments like field
, from
for renaming, custom
for custom values, and ignore
for ignoring the field.
We can access the element model and read mappr’s mappings (MapType
) and their fields (Field
) using the analyzer
package.
Handling annotation
This article assumes you have an Element
of the Mappr
class you marked with the @AutoMappr
annotation. Since you can also annotate methods or fields, you must first check that the element is a class by type-comparing it with a ClassElement
.
Now you can be sure you have a class element. To get the annotation element, access classElement.metadata
and take its only annotation, which is of type ElementAnnotation
. You can retrieve this element's constant value using computeConstantValue()
, which returns a DartObject
that is a concrete instance of your annotation, and you can now work with it more easily. You may, for example, use getField
that returns the selected field from the annotation and later cast it to the right type using toListValue()
, toIntValue
, and so on.
The value of mappersList
contains the list with MapType
s you declared in the annotation, transferred to DartObject
that describes it.
DartObject
vs. DartType
vs. Element
When working with @AutoMappr
annotation, you have a list of MapType<Source, Target>
mappings with two generic types for source and target. You can iterate over that list, and for each mapping, you can get a type of mapping using mapper.type! as ParameterizedType
. The ParameterizedType
is an abstract class of type with type arguments. Then you can select the source and target types from the mapperType.typeArguments
list.
In our case, we only have one MapType
with UserDto
as the sourceType
and User
as its targetType
.
To also retrieve field mappings, you can do similar stuff to the fields
field of MapType
and iterate over it to get its field
, from
, custom
, and ignore
values.
Note that you now use three types of objects: DartObject
, DartType
, and DartElement
. It’s really easy to confuse them because, most of the time, they can give you similar data. Let’s summarize the differences:
DartObject
– a concrete object you can cast to an object of various types liketo{int, Function, List, Map, Set, String, Symbol, Type}Value
. Imagine this as a concrete thing with its own value and type. → RepresentsMapType<UserDto, User>()
.DartType
– a concrete type used onDartObject
. You can think of it as an instance of a class in a type/element context. → RepresentsMapType<UserDto, User>
.Element
– an abstract type used as a “recipe” for types. Picture it as a class in a type/element context. → RepresentsMapType<Source, Target>
.
In the course of developing the auto_mappr
package, we wrongly used elements instead of types, which worked until we also wanted to support generics. While a TypeParameterizedElement
element has a typeParameters
list (e.g., T
, S
, …), ParameterizedType
type has a typeArguments
list (e.g., int
, String
, …). It’s fine in case you know it, but it’s nowhere to find in the README, and you cannot read all the files.
We also checked that sourceType
and targetType
are of type InterfaceType
. That is an abstract class for classes or interfaces, allowing access to a list of accessors
(list of getters and setters) with the type PropertyAccessorElement
, a list of constructors
with the type ConstructorElement
, and other useful things we did not use, like methods, mixins, or interfaces.
The DartType
has many booleans you can check: isDartCodeInt
, isDartCoreSet
, isDartCoreNull
, and so on. Those can be very helpful in your analysis.
Working with fields
For mapping, we need to work with source and target fields. Fields, or generically getters and setters, can be accessed on InterfaceType
’s accessors
property. That returns an already typed PropertyAccessorElement
, but you must distinguish between writable and readable accessors using the where
function. A writable accessor has set isPublic
, isSetter
, and !isStatic
fields — it has to be a setter, a public one so you can access it, and also non-static, so you only work with instance fields. A readable accessor has set isPublic
and isGetter
fields — once again, it has to be public due to access and a getter to read the value, but now you don’t limit static fields because you can use their values.
Const values
Annotation has to be constant. That means every argument you pass to it also has to be constant: a const value, a static function, or a global function. To allow passing a custom argument, make sure to compute its constant value.
It’s easy for functions: you call toFunctionValue()
on a field (DartObject
). Doing it for a custom value is where it gets a bit more complicated. If the DartObject
is a literal, you can convert it using to*Value()
. Unless that is iterable, you must recursively convert each item. For complex (custom) objects, you must use ConstantReader
to recreate the constructor call and recursively convert each positional and named argument. We will not paste the code here because it’s quite long, but you can check out the toCodeExpression
function for implementation details.
Note that ConstantReader
is from the source_gen
package that will be covered in another article.
Tips
If you need to check nullability, use nullabilitySuffix
on DartType
for it. It’s an enum for legacy reasons, and you can compare it with NullabilitySuffix.question
value.
If you want to support multiple types for your annotation’s field, for example, an int
or a String
, you can do that by trying to convert it to one, and if null, try the other one. All to*Value
functions return null
on failed conversion.
To analyze an iterable item’s type, do (type as ParametrizedType).typeArgument.first
on it. Don’t forget to check if the type is iterable by calling isDartCoreIterable
on DartType
.
Final thoughts
With a bit of practice and knowledge, analyzing Dart code is a relatively simple task that even a novice coder can breeze through. Hopefully this article aids you in your endeavors, so you don’t spend days figuring out which API to use.