ts-mapstruct
TS-Mapstruct is an approach of the JAVA MapStruct addapted in TypeScript.
It's a code generator that simplifies the implementation of mappings over configuration approach.
Table of contents
Installation
npm install ts-mapstruct
Recommendations
It is recommended that the DTO constructor can be empty for simplify the code, but it is not a problem if this is not the case.
This is a library that is designed to go hand in hand with Nestjs. It fits well with its layered architecture, and can be used in an Injectable. This can make dependency injection easier if you need other services to make your mapping.
But it can be used in any typescript project.
Usage
For the exemple, I will take a UserMapper that maps a UserDto into UserEntity.
Classes
export class UserDto {
@Expose() private fname: string;
@Expose() private lname: string;
@Expose() private bdate: number;
@Expose() private isMajor: boolean;
@Expose() private gender: GenderEnum;
@Expose() private friends: FriendDto[];
}
export class FriendDto {
@Expose() private friendlyPoints: number;
@Expose() private bdate: string;
public toString(): string {
return 'FriendDtoToString'
}
}
export class UserEntity {
@Expose() private fullName: string;
@Expose() private cn: string;
@Expose() private sn: string;
@Expose() private bdate: number;
@Expose() private isMajor: boolean;
@Expose() private lastConnexionTime: number;
@Expose() private bestFriend: FriendEntity;
@Expose() private friends: FriendEntity[];
constructor(fullName?: string) {
this.fullName = fullName;
}
}
export class FriendEntity {
@Expose() private friendlyPoints: number;
@Expose() private bdate: Date;
public toString(): string {
return 'FriendEntityToString'
}
}
Note: You must expose the properties of the target class by using an apropriate decorator.
Otherwise, you will retrieve an empty Object, and probably have MapperExceptions.
- In this example, I'm using @Expose() decorator of the class-tranformer library
- You can also define well named getters/setters for properties.
Mapper
@Mapper()
export class UserMapper {
@Mappings(
{ target: 'fullName', expression: 'getConcatProperties(userDto.fname, userDto.lname)' },
{ target: 'cn', source: 'userDto.fname' },
{ target: 'sn', source: 'userDto.lname' },
{ target: 'lastConnexionTime', value: Date.now() },
{ target: 'bestFriend', expression: 'getBestFriend(userDto.friends)' }
)
entityFromDto(_userDto: UserDto): UserEntity {
return new UserEntity;
}
entitiesFromDtos(userDto: UserDto[]): UserEntity[] {
return userDto.map(userDto => this.entityFromDto(userDto));
}
getBestFriend(friends: FriendDto[]): FriendDto {
return friends.reduce((acc: FriendDto, cur: FriendDto) => {
return acc.friendlyPoints > cur.friendlyPoints ? acc : cur;
})
}
}
Usage
@Injectable()
export class UserService {
constructor(private userMapper: UserMapper) {}
getUser(userDto: UserDto): UserEntity {
return this.userMapper.entityFromDto(userDto);
}
getUsers(userDtos: UserDto[]): UserEntity[] {
return this.userMapper.entitiesFromDtos(userDtos);
}
}
const userDto = new UserDto()
const userMapper = new UserMapper()
const userEntity = userMapper.entityFromDto(userDto)
Type conversion
The TS code is trans-compiled in JS before being executed, so the types of the source objects are kept on the end object.
In the previous example, the friends and bestFriends properties will remain FriendDto and not FriendEntity, and the same for the methods, they will be those of FrienDto.
The library allows you to define the targeted type for each property:
@Mappings(
{
target: 'bestFriend',
expression: 'getBestFriend(userDto.friends)',
type: FriendEntity
},
{ target: 'friends', type: FriendEntity }
)
entityFromDto(_userDto: UserDto): UserEntity {
return new UserEntity;
}
If you have multiple depths in your object, you can target the right property with the right type like this:
@Mappings(
{
target: 'bestFriend',
expression: 'getBestFriend(userDto.friends)',
type: FriendEntity
},
{ target: 'friends', type: FriendEntity },
{ target: 'friends.bdate', type: Date },
{ target: 'bestFriend.bdate', type: Date }
)
entityFromDto(_userDto: UserDto): UserEntity {
return new UserEntity;
}
Below are examples of options that may exist:
{ target: 'prop', type: Date }
{ target: 'prop', type: String }
{ target: 'prop', type: Boolean }
{ target: 'prop', type: Number }
{ target: 'prop', type: 'string' }
{ target: 'prop', type: 'boolean' }
{ target: 'prop', type: 'number' }
{ target: 'prop', type: 'date' }
{
target: 'prop',
type: String,
dateFormat: ['Fr-fr', { dateStyle: 'full', timeStyle: 'long' }]
}
@BeforeMapping / @AfterMapping
These decorators are to be placed on internal methods of the mapper. They allow to execute them before or after the mapping.
The method invocation is only generated if all parameters can be assigned by the available source of the mapping method.
The recovery of the sources is done on the name of the arguments and not on the type. If you do not name the argument at the same way, the method will not be invoked.
@Mapper()
export class UserMapper {
@Mappings(
{ target: 'fullName', expression: 'getConcatProperties(userDto.fname, userDto.lname)' },
{ target: 'cn', source: 'userDto.fname' },
{ target: 'sn', source: 'userDto.lname' },
{ target: 'lastConnexionTime', value: Date.now() }
)
entityFromDto(_userDto: UserDto): UserEntity {
return new UserEntity;
}
@Mappings(
{ target: 'cn', source: 'commonName' },
{ target: 'sn', source: 'secondName' },
{ target: 'bestFriend', source: 'bestFriend' }
)
entityFromArgs(
_commonName: string,
_secondName: string,
_bestFriend: Friend
): UserEntity {
return new UserEntity;
}
@BeforeMapping()
checkBeforeMappingDto(userDto: UserDto): void {
if (userDto.fname === undefined || userDto.lname === undefined)
throw new Error('The commonName and secondName must be defined')
}
@BeforeMapping()
checkBeforeMappingArgs(commonName: string, secondName: string): void {
if (commonName === undefined || secondName === undefined)
throw new Error('The commonName and secondName must be defined')
}
@BeforeMapping()
checkBeforeMapping(userDto: UserDto, secondName: string): void {
if (userDto.fname === undefined || secondName === undefined)
throw new Error('The commonName and secondName must be defined')
}
@AfterMapping()
logAfterMapping(): void {
console.log('Mapping is finished.');
}
}
Note: if you return object from your @AfterMapping or @BeforeMapping function, it will not be considered.
@MappingTarget
The MappingTarget allows you to pass the resulting object throw the methods to perform some actions on it.
Injectable()
export class UserMapper {
@Mappings(
{ target: 'fullName', expression: 'getConcatProperties(user.fn, user.sn)' },
{ target: 'lastConnexionTime', value: Date.now() }
)
entityFromDto(@MappingTarget() _user: UserEntity, _userDto: UserDto): UserEntity {
return new UserEntity;
}
@Mappings(
{ target: 'cn', source: 'commonName' },
{ target: 'sn', source: 'secondName' },
{ target: 'bestFriend', source: 'bestFriend' }
)
entityFromArgs(
_commonName: string,
_secondName: string,
_bestFriend: Friend
): UserEntity {
return new UserEntity;
}
@BeforeMapping()
checkBeforeMappingArgs(@MappingTarget() user: UserEntity): void {
if (user.cn === undefined || user.sn === undefined)
throw new Error('The commonName and secondName must be defined')
}
@AfterMapping()
overrideUser(@MappingTarget(UserEntity) user: UserEntity): void {
user.isMajor = true;
}
}
Note: @MappingTarget is not used in the same way depending on the type of method in which it is used:
- In an @BeforeMapping method, the argument bound to the @MappingTarget decorator must also be found in the mapping method. Otherwise @BeforeMapping will not be invoked.
- In an @AfterMapping method, the argument bound to the @MappingTarget does not have to be in the mapping method. However, you must provide the return type of the mapping method for the @AfterMapping method to be invoked.
Mapping Options
MappingOptions is the object that you have to pass throw the @Mappings decorator. This is what it looks like:
| target | The target name property | true |
| source | The source name property | false |
| value | A direct value | false |
| expression | A JS expression | false |
| type | The type of the targeted property | false |
| dateFormat | Used to convert a Date to a String Array with the locale in 1st pos. and the options in 2nd. pos. (cf. Intl.DateTimeFormat for more informations) | false |
If a MappingOptions is not correctly filled, an error will be generated when instantiating the Mapper.
At least one of these fields must be completed: source, value, expression, type.
At most one of these fields must be completed: source, value, expression.
Supplied Mapping Functions
The mapper provides some functions to pass via the "expression" property to facilitate the mapping:
getConcatProperties(...properties: [...string, string]): string
getOrEmptyString(value: any): any | string
Exceptions thrown
The thrown exceptions are extends of the HttpException of nestjs/common.
BadExpressionExceptionMapper
Injectable()
export class UserMapper {
@Mappings(
{ target: 'fullName', expression: 'unknownMethod()' }
)
entityFromDto(_userDto: UserDto): UserEntity {
return new UserEntity;
}
}
IllegalArgumentNameExceptionMapper
Injectable()
export class UserMapper {
@Mappings(
{ target: 'cn', expression: 'getConcatProperties(getConcatProperties.fname)' }
)
entityFromDto(getConcatProperties: UserDto): UserEntity {
return new UserEntity;
}
}
InvalidMappingOptionsExceptionMapper
Injectable()
export class UserMapper {
@Mappings(
{ target: 'cn', value: 'Ugo', source: 'userDto.fname' }
)
entityFromDto(_userDto: UserDto): UserEntity {
return new UserEntity;
}
}
InvalidMappingTargetExceptionMapper
Injectable()
export class UserMapper {
@Mappings()
invalidMappingTargetExceptionMapper (@MappingTarget() _userDto: UserDto): UserEntity {
return new UserEntity()
}
}
InvalidSourceExceptionMapper
Injectable()
export class UserMapper {
@Mappings(
{ target: 'cn', source: 'userDto.unknownProperty' }
)
entityFromDto(_userDto: UserDto): UserEntity {
return new UserEntity;
}
}
InvalidTargetExceptionMapper
Injectable()
export class UserMapper {
@Mappings(
{ target: 'unknown', source: 'userDto.fname' }
)
entityFromDto(_userDto: UserDto): UserEntity {
return new UserEntity;
}
}