mapper
Basic Setup
Each mapper relies an an underlying set of Profile
objects to define what types are supported and how to map between them. Mappers are created with createMapper
function, which takes Profile[]
as it's only argument.
import { createMapper } from '@stackoverfloweth/mapper'
const mapper = createMapper(profiles)
mapper.map('source-key', source, 'destination-key')
Profiles
Each profile defines a value for sourceKey
and destinationKey
. These keys must extend string
and should be unique, beyond that the choice is irrelevant to the function of the mapper.
Here are a couple simple examples of Profile
objects
export const numberToString = {
sourceKey: 'number',
destinationKey: 'string',
map: (source) => {
return source.toString()
},
} as const satisfies Profile
export const numberToDate = {
sourceKey: 'number',
destinationKey: 'Date',
map: (source) => {
return new Date(source)
},
} as const satisfies Profile
The mapper will use the keys you define to provide type safety when calling map.
mapper.map('number', 123, 'string') // "123"
mapper.map('number', 123, 'Date') // Wed Dec 31 1969...
mapper.map('number', 123, 'potato') // ERROR TS:2345 Argument of type '"potato"' is not assignable to parameter of type '"string" | "Date"'
Anytime mapper.map
is called with source and/or destination keys that are not registered by a profile, it will throw the following error.
'Mapping profile not found'
Loading profiles automatically
This library provides a useful method for automatically loading profiles. If you store all of your profiles in the same folder with a simple barrel file.
└── src
├── models
└── maps
├── primitives.ts
├── foo.ts
├── bar.ts
└── index.ts
const profiles = await loadProfiles(() => import('src/maps'))
const mapper = createMapper(profiles)
Mapping an array
Because TSource
and TDestination
are not constrained, you can always define a profile that expects an array.
export const numbersArrayToNumbersSet: Profile<'array', unknown[], 'Set', Set<unknown>> = {
sourceKey: 'array',
destinationKey: 'Set',
map: (source) => {
return new Set(source)
},
}
However, if your goal is use the same mapping profile over an array of sources you can use either
const mapped = sources.map(source => mapper.map('source-key', source, 'destination-key'))
or the mapper provides a simpler method mapMany
, which takes an array of TSource
and returns an array TDestination
.
const mapped = mapper.mapMany('source-key', sources, 'destination-key')
Nesting profiles
Sometimes your business logic for mapping from TSource
to TDestination
might benefit from nesting profiles inside of other profiles. For example, if you have the following models
// src/models/order.ts
export type Order = {
orderId: string,
total: number,
items: Item[],
}
// src/models/item.ts
export type Item = {
itemId: string,
title: string,
description: string,
}
and you need to map from api models
// src/models/api/orderResponse.ts
export type OrderResponse = {
_id: ObjectId,
totalInPennies: number,
items?: ItemResponse[],
}
// src/models/api/itemResponse.ts
export type ItemResponse = {
_id: ObjectId,
title: string,
description?: string,
}
There are a couple opportunities to use the mapper from within the order profile. Both the ObjectId
from mongodb and Item
mapping logic is likely already encapsulated by another profile. This is where the mapper
argument provided to the profile map
method comes in handy.
export const orderResponseToOrder: Profile<'OrderResponse', OrderResponse, 'Order', Order> = {
sourceKey: 'OrderResponse',
destinationKey: 'Order',
map: (source, mapper) => {
return {
orderId: mapper.map('ObjectId', source._id, 'string'),
total: number,
items: mapper.map('ItemResponse', source.items ?? [], 'Item'),
}
},
}
However, there's a catch. Because the type safety is controlled by your application supplying the profiles to createMapper
, the library itself cannot have an accurate type for mapper
here. The type provided by default is AnyMapper
, which unlike the mapper you get back from createMapper
will have no constraints on keys or types.
The solution to this is to take advantage of ts duck typing by defining your own type for MapFunction
that has narrowed types but still satisfies the requirement of Profile.map
.
export const mapper = createMapper(profiles)
export type Mapper = typeof mapper
export type MapFunction<TSource, TDestination> = (source: TSource, mapper: Mapper) => TDestination
Now you can rewrite your order profile with type safety
import { MapFunction } from 'src/services'
const map: MapFunction<OrderResponse, Order> = (source, mapper) => {
return {
orderId: mapper.map('ObjectId', source._id, 'string'),
total: number,
items: mapper.map('ItemResponse', source.items ?? [], 'Item'),
}
}
export const orderResponseToOrder: Profile<'OrderResponse', OrderResponse, 'Order', Order> = {
sourceKey: 'OrderResponse',
destinationKey: 'Order',
map,
}
Notes
- What you chose to name the profile doesn't matter to the mapper. In these examples I used the pattern
${sourceKey}To${destinationKey}
but this key is not currently used byloadProfiles()
in any way.