Hi everyone, it’s been exactly a month (again) since my last blog 😄 and you’re probably getting bored of me saying that. Last month, I published a blog explaining why I wanted to create an AutoMapper
library in TypeScript
. In today’s blog, I am going to dive deeper into how it works in TypeScript
by officially introducing my library for the first time: @nartc/automapper
Check it out on:
With NodeJS
getting more and more popular, we start seeing a lot more Server-Side projects written in NodeJS
and problems with exposing the proper data slowly concerns many developers who are working on those projects. The concern shows clearer in particular with building APIs using DDD (Domain Driven Design) with a lot of DTOs / VMs (Data Transfer Object or View Model, these two terms can be used interchangeably). In other words, you do not want to expose your Domain Model outside of the Persistence Layer just in case the Application Layer in Multitier Architecture.
And in JavaScript
, being a Dynamic Programming Language, the above problem ”can be done” quite easily because there is nothing stopping you from assigning values to your heart content. However, doing so blindly or mindlessly can cost you your maintainability to your code-base which is extremely dangerous and well, costly. On the other hand, doing so repeatedly will be prone to errors and eventually all of these ”manual mappings” will become tech debts that are really hard to repay, especially Complex Mappings.
Let’s assume you are building a minimal AirBnB clone and you plan to have the following Domain Models:
Real scenario will have many more Domain Models like:
Notification
,Message
,Transaction
etc… to accommodate such platform. Here, I take out all the transaction-related to simplify this blog post.
and you want to your platform able to do the following:
Anyone can register to become a Person
in your application.
Person
has two main Role
: User
and Host
Person
will have their Role
default to User
A Person
with User
role can:
Listing
Booking
Booking
to pastBookings
Listing
to wishlist
Listing
to lastViews
listA Person
will become a Host
after they create their first Listing
and can:
Listing
Booking
requestFrom the above requirements, you can see that our Domain Models are having quite a complex relationship and circular dependencies
Person
can have a list of Listing
on lastViews
and wishlist
Person
can have a list of Booking
on pastBookings
Listing
must have a Person
as host
Booking
must have both Person
as user
and host
, and also Listing
as listing
Let us all assume that we don’t have to worry about Domain Models relationships like One-One, One-Many and Many-Many. We only focus on exposing our Models to the consumers.
Let’s look at our models in code:
// Person.ts
class Address {
street: string;
city: string;
state: string;
zip: string;
}
class Profile {
firstName: string;
lastName: string;
bio: string;
phone: string;
address: Address;
}
enum Role {
User = "user",
Host = "host",
}
class Person {
email: string;
password: string;
profile: Profile;
role: Role;
lastViews: Listing[];
wishlist: Listing[];
pastBookings: Booking[];
}
// Listing.ts
class GeoLocation {
lat: number;
lng: number;
}
class Listing {
name: string;
description: string;
price: number;
location: GeoLocation;
host: Person;
}
// Booking.ts
class Booking {
startDate: Date;
endDate: Date;
user: Person;
host: Person;
listing: Listing;
total: number;
}
Take note that even though we wrote out Models with Nested Schemas but in reality, those relationships should be managed by reference
(aka foreign key
, aka IDs
). To illustrate the point that data the consumers would get, we wrote it as nested.
As you can see, if we do not transform
our Domain Models before returning to the consumers, we will then introduce circular dependencies in the data shape that the consumers would get. And the response could get substantially large with all the deep circular nesting. Moreover, we also create some “security issue” (like password
) by exposing the Domain Models as they are: Person
-> Booking
-> Person
-> Booking
etc… To address this issue without any tools, we might come up with something like this:
// DTOs.ts
class ProfileDto {
name: string;
bio: string;
phone: string;
formattedAddress: string; // street + city + state + zip
constructor(profile: Profile) {
this.name = profile.name;
this.bio = profile.bio;
this.phone = profile.phone;
this.formattedAddress = profile.address.street + profile.address.city + ...;/
}
}
class PersonDto {
email: string;
role: Role;
profile: ProfileDto;
lastViews: ListingDto[];
wishlist: ListingDto[];
pastBookings: BookingDto[];
constructor(person: Person) {
this.email = person.email;
this.role = person.role;
this.profile = new ProfileDto(person.profile);
this.lastViews = person.lastViews.map(lv => new ListingDto(lv));
this.wishlist = person.wishlist.map(wl => new ListingDto(wl));
this.pastBookings = person.pastBookings.map(pb => new BookingDto(pb));
}
}
// to break the circular dependency here, we will then need to introduce another smaller Dto for Person
class PersonInfoDto {
email: string;
profile: ProfileDto;
constructor(person: Person) {
this.email = person.email;
this.profile = new ProfileDto(person.profile);
}
}
class ListingDto {
name: string;
description: string;
host: PersonInfoDto;
constructor(listing: Listing) {
this.name = listing.name;
this.description = listing.description;
this.host = new PersonInfoDto(listing.host);
}
}
class BookingDto {
host: PersonInfoDto;
listing: ListingDto;
startDate: Date;
endDate: Date;
total: number;
constructor(booking: Booking) {
this.host = new PersonInfoDto(booking.host);
this.listing = new ListingDto(booking.listing);
this.startDate = booking.startDate;
this.endDate = booking.endDate;
this.total = booking.total;
}
}
DTOs are very specific and will differ from project to project. Decision making process for this dumbed-down version of this particular use-case:
Profile.address
, we only want to display a formattedAddress
string.Person.lastViews
and Person.wishlist
, we would normally display like a list of cards with some minimal information. That’s why ListingDto
contains those information.Next, you might have noticed the constructor()
in each DTOs, we are essentially “map” the Domain Models to these DTOs manually by passing in the appropriate Domain Model in the constructor()
of a matching DTO. This approach is safe and it works well. In fact, our company uses this hand-written approach in one of our
NestJS backend. However, this approach is not a good solution because:
Domain <=> DTO
Let me recap, @nartc/automapper
implementation of AutoMapper
is an Object to Object mapping solution by convention. Before we get to how @nartc/automapper
can help us, allow me to introduce to all of you that are not familiar with AutoMapper
some terminologies so you can follow better:
Mapper
: This is the main object that will help with the mapping. Usually in an AutoMapper
implementation, Mapper
is exposed as a singleton. In @nartc/automapper
, there’s a singleton Mapper
. However, you can always instantiate another Mapper
if you wish to manage your Mapper
instance.Profile
: A Domain Model
can have at least 1 Profile
. A Profile
is a class that extends MappingProfileBase
and it represents a profile of ONE Domain Models and its matching DTOs.Mapping
: A Mapping
is a blueprint between two Models, usually a Domain Model and a DTO. The Mapper
can only proceed with the map operation if the Mapping
exists.Now, let’s start with the issues one by one, not in any particular order:
Profile
has stated, a Profile
can house all the mapping logics for a specific Domain Model. We have 3 Domain Models so we’ll have 3 Profilesclass PersonProfile extends MappingProfileBase {
constructor() {
super(); // needed for this.profileName
}
configure(mapper: AutoMapper): void {
throw new Error("Method not implemented.");
}
}
class ListingProfile extends MappingProfileBase {
constructor() {
super();
}
configure(mapper: AutoMapper): void {
throw new Error("Method not implemented.");
}
}
class BookingProfile extends MappingProfileBase {
constructor() {
super();
}
configure(mapper: AutoMapper): void {
throw new Error("Method not implemented.");
}
}
Let’s implement our PersonProfile.configure()
:
class PersonProfile extends MappingProfileBase {
constructor() {
super(); // needed for this.profileName
}
configure(mapper: AutoMapper): void {
mapper
.createMap(Profile, ProfileDto) // create the Mapping for Profile -> ProfileDto
.forMember(
d => d.name,
opts => opts.mapFrom(s => s.firstName + " " + s.lastName)
) // map from Profile.firstName and Profile.lastName to ProfileDto.name. TypeScript will help out here
.forMember(
d => d.formattedAddress,
opts =>
opts.mapFrom(
s => s.address.street + " " + s.address.city + " " + s.address.state
)
) // d stands for destination, s stands for source
.reverseMap(); // create the Mapping for ProfileDto -> Profile
mapper.createMap(Person, PersonDto).reverseMap(); // create the Mapping for Person -> PersonDto and PersonDto -> Person
}
}
Here, we say PersonProfile extends MappingProfileBase
to initiate our contract between PersonProfile
and MappingProfileBase
which is used internally by @nartc/automapper
. The constructor
is needed to grab PersonProfile
string and assign it to this.profileName
. Each instance of Mapper
will keep track of its profiles
by profileName
. Then we implement configure()
method which receives the Mapper
instance. Next, we proceed to start creating our first mappings by calling createMap()
method which follows Fluent Interface:
createMap(Profile, ProfileDto)
: this creates the Mapping for Profile -> ProfileDto
. This method also establishes mapping configurations for properties that are on both Profile
and ProfileDto
. Remember, by convention? So, Profile.bio
will be mapped to ProfileDto.bio
without you having to configure that.ProfileDto
in this case)? forMember()
is created for that purpose. Pass in a Selector Function to let Mapper
know which property you want to configure explicitly and another function that receives the ForMemberOptions
object which exposes different methods for you to configure the property being selected. Here, we want to configure ProfileDto.name
explicitly and we want to take Profile.firstName
value and Profile.lastName
value to assign to ProfileDto.name
. Therefore, we use mapFrom()
which takes in yet another Selector Function to get the value for ProfileDto.name
. TypeScript
will help out here with safe-typings. You will actually get an error if you’re trying to map a value that is different than a string
to ProfileDto.name
(which is a string
).
TypeError when trying to map unmatched value type
@nartc/automapper
follows as close to the original .NET AutoMapper API as possible.
formattedAddress
.reverseMap()
to create another Mapping for ProfileDto -> Profile
. Person -> PersonDto
reverseMap()
to create another Mapping for PersonDto -> Person
Why is createMap(Person, PersonDto)
so short/empty? Again, remember by convention? This is one of the strong point of an AutoMapper
. @nartc/automapper
will try to map matching properties on both Person
and PersonDto
for primitives, nested models and list. Here, we set PersonDto.profile
to ProfileDto
and we have already setup a Mapping for Profile -> ProfileDto
, @nartc/automapper
will be able to map from Person.profile
to PersonDto.profile
without any configuration because @nartc/automapper
will get the existing Mapping for Profile -> ProfileDto
to map profile
since a Mapping for any pair of Models is unique.
The same goes for Listing
and Booking
so to simplify, I will not be putting code for those in this blog. You can find a full example here: CodeSandbox
@nartc/automapper
currently supports a wide range of features:
so please check out Github to learn more.
Just want to get it out there, I work on this library knowing the limitations of TypeScript
on its reflection
capabilities. Hence, there are some flaws that are worth mentioning:
@nartc/automapper
.Reflection: @nartc/automapper
has a peer dependency on reflect-metadata
and two dependencies which are class-transformer
and lodash.set
. lodash.set
is pretty self-explanatory. So I will explain the other two:
@nartc/automapper
can make use of. Namely, plainToClass()
, @Expose()
and @Type()
.I have written about this in the previous blog on Why I (want to) build an AutoMapper in TypeScript?
And to fix and/or improve flaws, I need more trials and more flaws. Please, if it fits your use-case, give @nartc/automapper
a try.
Beside the core library @nartc/automapper
, I have also written a wrapper to be used in NestJS
which goes by nestjsx-automapper
on npm
. It is a part of NestJSX
opinionated set of modules written specifically for NestJS
. Check it out on Github
Currently, @nartc/automapper
is waiting for more feedbacks and issues so it can be improved upon. Tutorial wise, I plan to record a video tutorial beside this blog post so stay tune for that. It feels much nicer on video, I promise. That said, if you decide to give the library a try, keep the feedbacks/issues coming. TIA!
Thank you for reading. I’m looking forward to seeing you in the next blog 🚀
Written by Chau Tran