GithubHelp home page GithubHelp logo

ugoevola / ts-mapstruct Goto Github PK

View Code? Open in Web Editor NEW
12.0 1.0 2.0 433 KB

TS-Mapstruct is an approach of the JAVA MapStruct addapted in TypeScript.

License: MIT License

TypeScript 100.00%
decorators mapper mappers mapping mappings mapstruct typescript object-to-object classconverter auto-mapper

ts-mapstruct's Introduction

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 {

  /*-------------------*\
     UserDto -> User
  \*-------------------*/

  @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 {
    // good practice: allways return an empty typed Object
    // if you don't want expose the properties of the targeted object's class
    // you must return here an object with the property to map
    return new UserEntity;
  }

  entitiesFromDtos(userDto: UserDto[]): UserEntity[] {
    return userDto.map(userDto => this.entityFromDto(userDto));
  }

  /*-------------------*\
     Mapping methods
  \*-------------------*/
  
  getBestFriend(friends: FriendDto[]): FriendDto {
    return friends.reduce((acc: FriendDto, cur: FriendDto) => {
      return acc.friendlyPoints > cur.friendlyPoints ? acc : cur;
    })
  }
}

Usage

// NestJs (decorate your mapper with @Injectable)
@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);
  }
}
// TypeScript
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:

  // Below are good to iterate on each properties of iterable
  { target: 'prop', type: Date }
  { target: 'prop', type: String }
  { target: 'prop', type: Boolean }
  { target: 'prop', type: Number }
  // Below are good to take the whole of the iterable object
  { target: 'prop', type: 'string' }
  { target: 'prop', type: 'boolean' } // force the String 'false' to false
  { target: 'prop', type: 'number' }
  { target: 'prop', type: 'date' }
  // used to convert Date to string with a specific format
  // cf. Intl.DateTimeFormat for more informations
  {
    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 {

  /*-------------------*\
     UserDto -> User
  \*-------------------*/

  @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 { 
    // @deprecated
    // there, you can perform of some actions before the mapping execution,
    // but this was not planned for.
    // The behavior is therefore not guaranteed.
    // use @BeforeMapping instead
    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
  \*-------------------*/

  // called before entityFromDto
  @BeforeMapping()
  checkBeforeMappingDto(userDto: UserDto): void {
    if (userDto.fname === undefined || userDto.lname === undefined)
      throw new Error('The commonName and secondName must be defined')
  }

  // called before entityFromArgs
  @BeforeMapping()
  checkBeforeMappingArgs(commonName: string, secondName: string): void {
    if (commonName === undefined || secondName === undefined)
      throw new Error('The commonName and secondName must be defined')
  }

  // never called
  @BeforeMapping()
  checkBeforeMapping(userDto: UserDto, secondName: string): void {
    if (userDto.fname === undefined || secondName === undefined)
      throw new Error('The commonName and secondName must be defined')
  }

  /*-------------------*\
      AfterMapping
  \*-------------------*/

  // allways called
  @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 {

  /*-------------------*\
     UserDto -> User
  \*-------------------*/

  @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
  \*-------------------*/

  // called only for entityFromDto
  @BeforeMapping()
  checkBeforeMappingArgs(@MappingTarget() user: UserEntity): void {
    if (user.cn === undefined || user.sn === undefined)
      throw new Error('The commonName and secondName must be defined')
  }

  /*-------------------*\
      AfterMapping
  \*-------------------*/

  // called for both entityFromDto AND entityFromArgs
  @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:

Properties Description Required
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:

/**
*  concatenates each property provided in the running order.
*  The last argument is the separator
*  @params properties: properties to concat and the separator
*  @return string: the concatenation of each properties
*  @required each property must be a string
*/
getConcatProperties(...properties: [...string, string]): string
/**
*  return an empty string if the value is undefined or null
*/
getOrEmptyString(value: any): any | string

Exceptions thrown

The thrown exceptions are extends of the HttpException of nestjs/common.

BadExpressionExceptionMapper

Injectable()
export class UserMapper {
  
  // this will throw a BadExpressionExceptionMapper
  // because the expression for fullName
  // can't be evaluated (unknownMethod does not exist)
  @Mappings(
    { target: 'fullName', expression: 'unknownMethod()' }
  )
  entityFromDto(_userDto: UserDto): UserEntity {
    return new UserEntity;
  }
}

IllegalArgumentNameExceptionMapper

Injectable()
export class UserMapper {
  
  // This will throw an IllegalArgumentNameExceptionMapper
  // because getConcatProperties is a reserved name used for supplied mapping funcions
  // All supplied mapping function name are forbidden for naming the arguments.
  // cf. Supplied Mapping Functions
  // this exception is thrown as soon as there is an expression in one provided MappingOptions
  @Mappings(
    { target: 'cn', expression: 'getConcatProperties(getConcatProperties.fname)' }
  )
  entityFromDto(getConcatProperties: UserDto): UserEntity {
    return new UserEntity;
  }
}

InvalidMappingOptionsExceptionMapper

Injectable()
export class UserMapper {
  
  // this will throw an InvalidMappingOptionsExceptionMapper
  // because you provide multiple sources (value and source)
  // for cn in one MappingOption
  @Mappings(
    { target: 'cn', value: 'Ugo', source: 'userDto.fname' }
  )
  entityFromDto(_userDto: UserDto): UserEntity {
    return new UserEntity;
  }
}

InvalidMappingTargetExceptionMapper

Injectable()
export class UserMapper {
  
  // this will throw an InvalidMappingTargetExceptionMapper
  // because the provided @MappingTarget object
  // does not have the type of the returned mapping function
  @Mappings()
  invalidMappingTargetExceptionMapper (@MappingTarget() _userDto: UserDto): UserEntity {
    return new UserEntity()
  }
}

InvalidSourceExceptionMapper

Injectable()
export class UserMapper {
  
  // this will throw an InvalidSourceExceptionMapper
  // because userDto.unknownProperty does not exist
  @Mappings(
    { target: 'cn', source: 'userDto.unknownProperty' }
  )
  entityFromDto(_userDto: UserDto): UserEntity {
    return new UserEntity;
  }
}

InvalidTargetExceptionMapper

Injectable()
export class UserMapper {
  
  // this will throw an InvalidTargetExceptionMapper
  // because unknown does not exist on UserEntity
  @Mappings(
    { target: 'unknown', source: 'userDto.fname' }
  )
  entityFromDto(_userDto: UserDto): UserEntity {
    return new UserEntity;
  }
}

ts-mapstruct's People

Contributors

snyk-bot avatar ugoevola avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar

ts-mapstruct's Issues

ts-mapstruct is not working with sequelizer

Not sure what is wrong, but getting this error while mapping properties.
Entities are annotated with sequelizer and DTO are having normal attributes

throw new model_not_initialized_error_1.ModelNotInitializedError(new.target, ${new.target.name} cannot be instantiated.);
^
Error: Model not initialized: PersonEntity cannot be instantiated. "PersonEntity" needs to be added to a Sequelize instance.

How do I use overloading through inteface?

I am using nestjs, class-transformer, and plan to further customize it using ts-mapstruct.
I am trying to implement overloading with a method called of through an interface. How should I use it?

export interface OrderMapper {
    of(_source: Order): OrderInfo;
    of(_source: OrderItem): OrderItemInfo;
}

@Mapper()
export class OrderMapperImpl implements OrderMapper {
      of(_order: Order): OrderInfo;
      of(_order: OrderItem): OrderItemInfo;
    
      @Mappings()
      of(_source: Order | OrderItem): OrderInfo | OrderItemInfo {
          return ...
      }
}

MapStructException: The target "X" does not correspond to any property of the targeted type.

The ts-mapstruct library uses the plainToClass with the exposeAll strategy of the class-transformer library to retrieve all properties of the targeted class.

But it doesn't seem to work properly: typestack/class-transformer#740

To don't have this error, you must decorate your class properties with @expose() like that:

export class Entity {
  @Expose() property1: any;
  @Expose() property2: any;
}

export class Mapper {
  @Mapper(
    {target: 'property1', source: 'prop1'}
  )
  getEntity(prop1): Entity {
    return new Entity;
  }
}

How to map array types?

The doc seems say to little about how to map array of types.

For example, suppose I have two similar classes:

export class ContactCO {
  countryCode: string;
  cityCode: string;
  phone: string;
  extensionNo: string;
}

// ---

export class ContactNumber {
    countryCode?: Nullable<string>;
    areaCode?: Nullable<string>;
    main?: Nullable<string>;
    extension?: Nullable<string>;
    phone?: Nullable<string>;
}

and I want to map ContactCO[] into ContactPhone[], how can I write the mapping options?

// this seems doesn't work as expected
@Mapping(
    {
      target: "contactNumbers",
      source: "useRuleCO.contactList",
      type: ContactNumber,
    },
    {
      target: "contactNumbers.main",
      source: "useRuleCO.contactList.phone", // convert phone field into main field
    },
)

does ts-mapstruct supports this kind of auto mapping?

Mapping object with null properties is not working

Mapping an object containing a property with null value is not working. The error:

TypeError: Cannot convert undefined or null to object
at Function.entries ()
at Object.clean (/opt/microservices/node_modules/ts-mapstruct/dist/src/utils/utils.js:89:12)
at /opt/microservices/node_modules/ts-mapstruct/dist/src/utils/utils.js:94:21
at Array.forEach ()
at Object.clean (/opt/microservices/node_modules/ts-mapstruct/dist/src/utils/utils.js:89:28)
...

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    ๐Ÿ–– Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. ๐Ÿ“Š๐Ÿ“ˆ๐ŸŽ‰

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google โค๏ธ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.