Automate object mapping in Dart with auto_mappr
Automate object mapping in Dart with auto_mappr
Automate object mapping in Dart with auto_mappr

Automate object mapping in Dart with auto_mappr

Flutter

Flutter

Flutter

In need for object mapping package in Dart? We have you covered!

3. 4. 2023

This article is part of the series Code generation in Flutter:

Think of a project that divides a project into layers that do not depend on each other. Or a project that at least tries to separate DTO objects from API calls from models used in an app. Some apps separate domain layer models into UI models, etc. In my experience, most projects meet such scenarios and, at one point or another, have to do object-to-object mapping to transfer one object to another. Some architectures, like Clean Architecture, naturally force you to such a separation. It might be tens, hundreds, or even thousands of mappings that developers have to maintain.

While object-to-object mappings are easy to write by hand, the repetition can get really boring in large numbers. And maintaining it can be hell. These objects have similar, if not exact, fields so that it can be automated well. Nonautomated mappings can get tedious to write, maintain, and, well, it’s for sure an unnecessary boilerplate. Tools that help you with that can save a lot of time!

What about existing packages?

There are several packages available for automating object-to-object mapping in Dart, and while we have tried many of them in various projects, we have found that they can be prone to bugs and lack the features we require. Despite reporting issues or contributing pull requests, we have found that these issues may go unaddressed for extended periods of time. To address these challenges, we have continued to search for better solutions that can more effectively meet our needs.

Introducing the auto_mappr package

For the reasons above and while envisioning how it would streamline our work on apps, we first tried to design a better way to do mappings. We already knew AutoMapper from C# and other similar packages, so those were a base for what we wanted. Hence, we experimented with code generation because it’s fast at runtime and makes code debugging easier for developers. We believe all packages should prioritize making their tools fast and debuggable rather than relying on hidden magical functionality.

Therefore, we decided to create the auto_mappr package. It uses simple syntax and code generation that produces quite good results. We implemented a vast amount of features in the first version, so the novelty of the package should not limit you. So yeah, try it and let us know in GitHub issues or at our linked Discord what you think about it. You can always create an issue to request a new feature you would like or even do a PR by yourself. But do create an issue and discuss it first to ensure we are on the same page. 🙌

flutter package netglade automappr

AutoMappr on pub.dev (NetGlade)

The initial release has many features. Let me talk a bit about them and give you a tour of how it works.

First, the package works around a mapper class; we call them mapprs to match the package’s name. Add the @AutoMappr annotation and a list of object-to-object mappings you want this mapper to convert, for this class. That is done by MapType<Source, Target>(), where the source and the target are the types of objects you want to generate mapping for. In the example below, there is a mapping from UserDto to User.

@AutoMappr([
  MapType<UserDto, User>(),
])
class Mappr extends $Mappr {}

To convert one object to another, instantiate Mappr and call the convert method on it. Note that the convert function has two generic parameters — source and target. If you care about strict type inference, either assign the result of converting to an explicitly typed variable or explicitly state generics. The function cannot infer the generic parameters just from the source parameter.

void main() {
  final mappr = Mappr();

  // convert like this
  User user = mappr.convert(UserDto(...));

  // or like this
  final user2 = mappr.convert<UserDto, User>(UserDto(...));
}

Complex objects

Mapping works for fields with primitive types (like int, double, bool, String, etc.) by default. But when you use complex (custom) objects, AutoMappr has to know to which target object it should map the source. That means you must also add mappings between all those nested objects you use. As before, add MapType<Source, Target>(), where the source and the target are the types of nested objects you want to generate mapping for.
Imagine a user has a custom object with their address (its fields have only primitive types). The code would have to be changed to this:

@AutoMappr([
  MapType<UserDto, User>(),
  MapType<AddressDto, Address>(),
])
class Mappr extends $Mappr {}

Renaming

What if there’s an age field in UserDto and a myAge field in User? Mapper cannot map fields with not matched names. To help it, you can use the Field or Field.from constructor to override the field mapping. The first positional argument is the target field name, and the from argument is a source field name.

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

Custom mapping

And now, what if there are firstName and lastName fields in UserDto, but in User there is only the fullName field? The mapper will not be able to generate the correct mapping. But you can create your own custom mapping. Use the Field or Field.custom (specialized without other arguments) constructor to customize the field mapping. The custom argument accepts either a static function that takes a source as its parameter and outputs a target’s field type or a const value.

@AutoMappr([
  MapType<UserDto, User>(
    fields: [
      Field('fullName', custom: Mappr.mapFullName), // static Mappr method
      // Field('fullName', custom: mapName), // global method
      // Field('fullName', custom: 'Obi-Wan Kenobi'), // const value
    ],
  ),
])
class Mappr extends $Mappr {
  static String mapFullName(UserDto dto) => '${dto.firstName} ${dto.lastName}';
}

String mapName(UserDto dto) => 'Mr. ' + dto.firstName;

Ignoring

Sometimes you want to ignore some target field even if a source has such a field, which you do not want to map. For that case, use the Field or Field.ignore (specialized without other arguments) constructor to ignore the field mapping. Use the ignore argument to specify the target field name. Note that the target field must be nullable, or the generated mapping would not be valid for apparent reasons. Let’s ignore the favoriteColor field.

@AutoMappr([
  MapType<UserDto, User>(
    fields: [
      Field('favoriteColor', ignore: true),
    ],
  ),
])
class Mappr extends $Mappr {}

Collections

All mentioned rules also apply to collections, whether a list, set, collection, or map. If you have a collection of complex objects, you have to set up an object mapping for it using MapType, as already mentioned.

Nullability and default values

There are two possibilities for default values — the whole object or a field. When a source object is null, to set up a default target value, use whenSourceIsNull argument on MapType. When a field is null, you can set up whenNull argument on Field (or some of its specific constructors). The value can be either a static function without a parameter or a constant value for both cases.

Imagine that UserDto and User have fields String firstName, String? lastName, and int age. This is how to use it:

@AutoMappr([
  MapType<UserDto, User>(
    fields: [
      Field('lastName', whenNull: 'Snow'),
    ],
    whenSourceIsNull: User(firstName: 'John', lastName: 'Wick', age: 55),
  ),
])
class Mappr extends $Mappr {}

When mapping UserDtoUser with value UserDto('John', age: 42), the result will be User('John', 'Snow', age: 42) because whenNull was applied.

When mapping UserDtoUser with value null, the result will be User(firstName: 'John', lastName: 'Wick', age: 55) because whenSourceIsNull was applied.

Generics

Models with generics are also supported. Note that for the mappr to work correctly, you must explicitly map all type arguments you’ll use. Generic arguments might be both primitive and complex objects; there should be no limitation.

@AutoMappr([
  MapType<UserDto<int>, User<int>>(),
  MapType<UserDto<String>, User<String>>(),
])
class Mappr extends $Mappr {}

Constructors and setters

By default, auto_mappr selects the constructor with the most parameters. Other not mapped fields are mapped using the target’s setters, if possible. If you want to ignore fields that you don’t want to map using setters, simply use the ignore parameter. When you want to use a specific constructor, you can select it using the constructor parameter on MapType.

Constructors work every combination of source and target parameters, no matter if they are positional, optional, or named. The tool will automatically assign them according to a matching name.

3rd party libraries and tools

The package can use both shared and not shared builders to generate. The default one is the shared version so that it can generate everything to the .g.dart output. Additionally, we tested that it works with json_serializable or equatable, too.

It also works with packages that generate source or target for you, such as drift. In that case, you have to switch to not shared builders, optionally, with also specifying dependencies on sources.

Read more about it in the chapter dedicated to customizing the build in README.

Final thoughts

Automatic object-to-object mapping can be very useful in reducing boilerplate code, saving time, and simplifying the whole process. AutoMappr aims to help you with that. And because we use this tool in our own apps, we will continue to use it and develop it further. 🙌

Try it yourself and let us know on our Discord how you liked it or if you have ideas for improvement.

Further readings

AutoMappr on pub.dev