Property mapper
A solution to map structures in typescript Node projects. This solution provides decorators and types to make it easier to use.
Quick start
Install the dependency
npm install @woodchopper/property-mapper
Mapper example:
import {AbstractMapper, Mapping} from "@woodchopper/property-mapper";
@Mapping(
{ source: 'fName', target: 'firstName' },
{ source: 'lName', target: 'lastName' }
)
export class MyFirstMapper extends AbstractMapper<UserInfo, UserDetails> {}
Usage:
const userInfo: UserInfo = {
fName: 'foo',
lName: 'bar'
}
const userMapper = new MyFirstMapper();
const userDetails = userMapper.map(userInfo);
/*
userDetails.firstName is 'foo'
userDetails.lastName is 'bar'
*/
Usage
Mapping annotation can decorate both classes and methods.
Dedicated mapper
When using the decorator at class level, the class needs to extends AbstractMapper<S, T>. S is the type of the source object while T is the target type.
AbstractMapper contains the methods map
and arrayMap
that cast the objets given in argument from S to T. This is the responsibility of your @Mapping decorators to map the input to an output that match T.
For those given types:
type UserInfo = {
fName: string
lName: string
age: number
};
type UserDetails = {
firstName: string
lastName: string
age: number
};
A correct mapper would be:
@Mapping(
{ source: 'fName', target: 'firstName' },
{ source: 'lName', target: 'lastName' }
)
export class UserMapper extends AbstractMapper<UserInfo, UserDetails> {}
or
@Mapping({ source: 'fName', target: 'firstName' })
@Mapping({ source: 'lName', target: 'lastName' })
export class UserMapper extends AbstractMapper<UserInfo, UserDetails> {}
A property that is not mapped will simply go from the source to the target with the same key and value. In our example, age
will be present on object of type UserDetails after the mapping.
Basic mapping instructions
source
and target
can define paths to properties. This path expression is defined by a string. the source
expression support jsonPath syntax.
Example:
@Mapping({ source: 'details.age', target: 'personnalDetails.age' })
export class UserMapper extends AbstractMapper<UserInfo, UserDetails> {}
Another example with jsonPath:
@Mapping({ source: '$.details.phoneNumbers[:1].number', target: '$.personnalDetails.mainPhoneNumber' })
export class UserMapper extends AbstractMapper<UserInfo, UserDetails> {}
With multiple source
If the source provide multiple results, multipleSources
needs to be used instead of source
.
@Mapping({ multipleSources: '$.details.phoneNumbers[:].number', target: '$.personnalDetails.phoneNumbers' })
export class UserMapper extends AbstractMapper<UserInfo, UserDetails> {}
Side note: a jsonPath
expression always returns an array of results. When using source
: the first element of the results will be used. When using multipleSources
: all the elements of the result will be used.
With transformation
The mapping instruction can provide transformations:
Example:
@Mapping({ source: 'lName', target: 'lastName', transform: (name: string) => name.toUpperCase() })
export class UserMapper extends AbstractMapper<UserInfo, UserDetails> {}
Or with a source that provide an array:
@Mapping({ source: '$.details.phoneNumbers[:].number', target: '$.personnalDetails.phoneNumbers', transformEach: (phoneNumber: string) => phoneNumber.trim() })
export class UserMapper extends AbstractMapper<UserInfo, UserDetails> {}
When source
and target
share the same property name, you can use sourceTarget
:
@Mapping({ sourceTarget: 'lastName', transform: (name: string) => name.toUpperCase() })
export class UserMapper extends AbstractMapper<UserInfo, UserDetails> {}
With arguments
Arguments can be passed to the mapper:
@Mapping({ source: 'lName', target: 'lastName', transform: (name: string, title: string) => title + ' ' + name })
export class UserMapper extends AbstractMapper<UserInfo, UserDetails> {}
const userMapper = new UserMapper();
const userDetails = userMapper.map(userInfo, 'Dr.');
Using multiple arguments will result to this:
@Mapping({ /* ... */ transform: (name: string, arg1: any, arg2: any, arg3: any) => /* ... */ })
/* ... */
userMapper.map(userInfo, arg1, arg2, arg3)
If you need to deal with a lot of arguments, a wise way to use it is by using a context object:
@Mapping({ source: 'lName', target: 'lastName', transform: (name: string, context: {}) => context.title + ' ' + name })
@Mapping({ sourceTarget: 'phoneNumber', transform: (phoneNumber: string, context: {}) => context.phonePrefix + phoneNumber })
export class UserMapper extends AbstractMapper<UserInfo, UserDetails> {}
const userMapper = new UserMapper();
const userDetails = userMapper.map(userInfo, { title: 'Dr.', phonePrefix: '+32' });
Using another AbstractMapper as dependency
Transformations can also use other mappers. This is useful when dealing with nested Objects of multiple types
type UserInfo = {
fName: string
lName: string
};
type UserDetails = {
firstName: string
lastName: string
};
type Website = {
numberOfActiveUsers: number,
users: UserInfo[]
}
type WebsiteDetails = {
activity: string,
users: UserDetails
}
A correct mapper would be:
@Mapping(
{ source: 'numberOfActiveUsers', target: 'activity', transform: (v: number) => v + ' of active users' },
{ sourceTarget: 'users', transformEach: UserMapper }
)
export class WebsiteMapper extends AbstractMapper<Website, WebsiteDetails> {}
or
@Mapping(
{ source: 'numberOfActiveUsers', target: 'activity', transform: (v: number) => v + ' of active users' },
{ sourceTarget: 'users', transformEach: new UserMapper() }
)
export class WebsiteMapper extends AbstractMapper<Website, WebsiteDetails> {}
For some reason, you could provide your own implementation of UserMapper by injecting it:
@Mapping(
{source: 'numberOfActiveUsers', target: 'activity', transform: (v: number) => v + ' of active users'},
{sourceTarget: 'users', transformEach: UserMapper}
)
export class WebsiteMapper extends AbstractMapper<Website, WebsiteDetails> {
constructor(private userMapper: UserMapper) {
super();
}
}
/**
* overrides the mappings for firstName and lastname defined in UserMapper
*/
@Mapping({ target: 'firstName', transform: () => 'HIDDEN' })
@Mapping({ target: 'lastName', transform: () => 'HIDDEN' })
class GDPRCompliantUserMapper extends UserMapper {}
const websiteMapper = new WebsiteMapper(new GDPRCompliantUserMapper());
const websiteDetails = websiteMapper.map(website);
Remove instruction
type UserInfo = {
fName: string
lName: string
};
type UserDetails = {
firstName: string
lastName: string
};
All those examples provide ways from mapping UserInfo to UserDetails. While we expect user details to be a plain object of this form:
{
"firstName": "John",
"lastName": "Doe"
}
It is in fact of this form:
{
"firstName": "John",
"lastName": "Doe",
"fName": "John",
"lName": "Doe"
}
The properties of the source object remains. When working with this object, if you stick to the definition of the type UserDetails, you should not have any issue.
If those remaining properties bother you in you dev, you need to explicitly remove them:
@Mapping({ source: 'fName', target: 'firstName' }, { remove: 'fName'})
@Mapping({ source: 'lName', target: 'lastName' }, { remove: 'lName'})
export class UserMapper extends AbstractMapper<UserInfo, UserDetails> {}
Or
@Ignore('fName', 'lName')
@Mapping({ source: 'fName', target: 'firstName' })
@Mapping({ source: 'lName', target: 'lastName' })
export class UserMapper extends AbstractMapper<UserInfo, UserDetails> {}
You will get:
{
"firstName": "John",
"lastName": "Doe"
}
Angular injection
When working with the annotation @Injectable
from Angular, you could get issues while trying to inject a mapper. You need to explicitly provide the mapper in you Module:
@Injectable()
@Mapping({ source: 'fName', target: 'firstName' })
@Mapping({ source: 'lName', target: 'lastName' })
export class UserMapper extends AbstractMapper<UserInfo, UserDetails> {}
@NgModule({
/* ... */
providers: [
/* ... */
{
provide: MapperClassService,
useValue: new MapperClassService()
}
/* ... */
],
/* ... */
})
export class AppModule { }
Mapping on methods
The decorator can also be used on class methods. This feature can be used to perform some small mapping.
Example:
type UserInfo = {
fName: string
lName: string
inscriptionDate: Date
};
class UserClient {
constructor(/* an async http client */) {}
@Mapping({sourceTarget: 'inscriptionDate', transform: (date: string) => new Date(date)})
getUser(id: string): Observable<UserInfo> {
/* some async call to an API */
}
}
Here the Mapping instruction convert the date in format string
to an object of type Date
. So that the model UserInfo
is respected.
Those method mappings can be performed on methods returning objects of type Observable, Promise or plain objects.
It can also be used on Array return types:
class UserClient {
constructor(/* an async http client */) {}
@Mapping({sourceTarget: 'inscriptionDate', transform: (date: string) => new Date(date)})
getAllUsers(id: string): Observable<UserInfo[]> {
/* some async call to an API */
}
}