Code generation in Flutter: analyzing
Code generation in Flutter: analyzing
Code generation in Flutter: analyzing

Code generation in Flutter: analyzing

Flutter

Flutter

Flutter

In Flutter, code generation is all the rage. But your journey starts with a good analysis.

13. 6. 2023

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.

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 fieldfrom for renaming, custom for custom values, and ignore for ignoring the field.

@AutoMappr([
  MapType<UserDto, User>(
    fields: [
      Field('myAge', from: 'age'),
    ],
  ),
])
class Mappr extends $Mappr {}

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.

if (classElement is! ClassElement) {
  throw InvalidGenerationSourceError(
    '${classElement.displayName} is not a class and cannot be annotated with @AutoMappr.',
    element: classElement,
    todo: 'Use @AutoMappr annotation on a class',
  );
}

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.

ElementAnnotation annotationElement = classElement.metadata.single; // AutoMap annotation
DartObject constant = annotationElement.computeConstantValue()!; // its instance
DartObject mappersField = constant.getField('mappers')!;
List<DartObject> mappersList = mappersField.toListValue()!;

The value of mappersList contains the list with MapTypes 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.

mappersList.map((mapper) {
  final mapperType = mapper.type! as ParameterizedType;

  DartType sourceType = mapperType.typeArguments.first;
  DartType targetType = mapperType.typeArguments.last; 
}

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 fieldfromcustom, and ignore values.

List<DartObject>? fields = mapper.getField('fields')?.toListValue();

fields?.map((fieldMapping) {
  String? field = fieldMapping.getField('field')!.toStringValue()!;
  bool ignore = fieldMapping.getField('ignore')!.toBoolValue()!;
  String? from = fieldMapping.getField('from')!.toStringValue();
  ...
});

Note that you now use three types of objects: DartObjectDartType, 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 like to{int, Function, List, Map, Set, String, Symbol, Type}Value. Imagine this as a concrete thing with its own value and type. → Represents MapType<UserDto, User>().

  • DartType – a concrete type used on DartObject. You can think of it as an instance of a class in a type/element context. → Represents MapType<UserDto, User>.

  • Element – an abstract type used as a “recipe” for types. Picture it as a class in a type/element context. → Represents MapType<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., TS, …), ParameterizedType type has a typeArguments list (e.g., intString, …). 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 constructorswith the type ConstructorElement, and other useful things we did not use, like methods, mixins, or interfaces.

The DartType has many booleans you can check: isDartCodeIntisDartCoreSetisDartCoreNull, 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 isPublicisSetter, 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.questionvalue.

If you want to support multiple types for your annotation’s field, for example, an intor 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.

int? myIntValue = mapper.getField('myField')?.toIntValue();
String? myStringValue = mapper.getField('myField')?.toStringValue();

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.

Further readings

auto_mappr | pub.dev

analyzer | pub.dev