"Exhaustive", type-safe object mapper. Use this when you want to ensure every property on your output object has been mapped.
▶Example 1
Example 1
import { mapFrom } from "./src/map-from.ts"; import { ObjectMapper } from "./src/object-mapper.ts"; import { OmitProperty } from "./src/omit-property.ts"; // Set up the types and object mapper. // This will be our input type. It could represent some database row. interface UserEntity { email: string; firstName: string; lastName: string; permissions: Array<'create' | 'delete' | 'read' | 'update'>; username: string; } // This will be our output type. It is some Data Transfer Object (DTO). // Perhaps we send this data structure in a JSON API response body. interface UserDto { fullName: string; permissions?: UserEntity['permissions']; username: string; } interface UserMappingContext { requesterAccess: 'admin' | 'user'; } // We create an instance of `ObjectMapper` by calling `ObjectMapper.create()({ ... })`. // Yes, that's a double-function-call. `ObjectMapper.create()` returns a function, which // accepts a schema, and returns an `ObjectMapper`. This is to make use of a neat type-safety trick. // You should only create one global instance, you don't need to create one each time you // want to map an object. const objectMapper = ObjectMapper.create<UserEntity, UserDto, UserMappingContext>()({ // A mapper function with some logic fullName: (input) => `${input.firstName} ${input.lastName}`, // A mapper function that uses context permissions: (input, context) => context.requesterAccess === 'admin' ? input.permissions : OmitProperty, // A quick mapping shortcut, using the name of a property on the input object username: "username", }); // Then, when it's time to map something, in say an API endpoint... // We'll create an input object here, but this would normally come from // somewhere else, like an HTTP request body, or a database repository. const userEntity: UserEntity = { email: "bobt@pinafore.cruise", firstName: "Bob", lastName: "Terwilliger", permissions: ["delete"], username: "bterwilliger", }; // This mapper uses a context. const context: UserMappingContext = { requesterAccess: 'admin' }; // Invoke the mapper. const outputDto = objectMapper.map(userEntity, context); console.log(outputDto); // --> { fullName: "Bob Terwilliger", permissions: ["delete"], username: "bterwilliger" } // Prefer functions? const mapUserDto = objectMapper.toFunction(); const outputDto2 = mapUserDto(userEntity, { requesterAccess: "user" }); console.log(outputDto2); // --> { fullName: "Bob Terwilliger", username: "bterwilliger" } // Mapper functions can be reused from an existing mapper's schema. // Also, context is optional. const objectMapperNoContext = ObjectMapper.create<UserEntity, UserDto>()({ fullName: objectMapper.schema.fullName, permissions: mapFrom.omit, username: objectMapper.schema.username, }); const outputDto3 = objectMapperNoContext.map(userEntity); console.log(outputDto3); // --> { fullName: "Bob Terwilliger", username: "bterwilliger" }
There is a separate AsyncObjectMapper
, which uses Promises and async/await,
for each mapper function. (However, I recommend performing your async
operations before mapping, and putting values in the mapper context,
to avoid side effects and make testing easier.)
Why?
You might be wondering, why add this complexity when a simple function or class does the same job?
vs. Function
You could just write a function:
function mapUserDto(input: UserEntity): UserDto { return { fullName: `${input.firstName} ${input.lastName}`, username: input.username, }; }
There's some disadvantages:
- We have forgotten to map
permissions
. TypeScript doesn't complain, because the property is optional. - It's tricky to compose mapping functions.
Let's say we have two versions of UserDto
; in v2, we return the first and last name
separately, and omit permissions entirely.
type UserDtoV1 = UserDto; interface UserDtoV2 { firstName: string; lastName: string; username: string; } function mapUserDtoV1(input: UserEntity, context: UserMappingContext): UserDtoV1 { return { fullName: `${input.firstName} ${input.lastName}`, permissions: context.requesterAccess === "admin" ? input.permissions : undefined, username: input.username, }; } function mapUserDtoV2Bad(input: UserEntity, context: UserMappingContext): UserDtoV2 { return { ...mapUserDtoV1( input, context // We don't actually _need_ the context for a V2 mapper, but must provide it for the V1 mapper ), firstName: input.firstName, lastName: input.lastName, }; } console.log(mapUserDtoV2Bad(userEntity, { requesterAccess: "admin" })); // { // fullName: "Bob Terwilliger", // should not be here // permissions: ["delete"], // should not be here // username: "bterwilliger", // firstName: "Bob", // lastName: "Terwilliger" // } function mapUserDtoV2Better(input: UserEntity, context: UserMappingContext): UserDtoV2 { return { // Assuming we have some `omit()` function... ...omit(mapUserDtoV1( input, context ), ['fullName', 'permissions']), firstName: input.firstName, lastName: input.lastName, }; } console.log(mapUserDtoV2Better(userEntity, { requesterAccess: "admin" })); // { // username: "bterwilliger", // firstName: "Bob", // lastName: "Terwilliger" // }
Even this "better" function is not ideal. We create an intermediate object for the UserDtoV1,
and another from the call to omit()
. If UserDtoV1
gets new properties, we might need to
add them to the omit()
keys array - the compiler won't warn us about this.
If UserDtoV2
adds an optional property, the compiler won't warn us that we've forgotten
to map it.
Finally, a function can do anything. It could perform side effects, such as fetching data from a database. Ideally, a mapping function only performs mapping.
vs. Class
You could construct the output type by instantiating a class. Each property value can be passed as an argument to the constructor.
class UserDtoImpl implements UserDto { public fullName: string public permissions?: UserDto['permissions'] public username: string constructor( input: UserEntity, context: UserMappingContext ) { this.fullName = `${input.firstName} ${input.lastName}`; this.username = input.username; } }
Disadvantages:
- We've forgotten to map
permissions
. TypeScript doesn't complain, because the property is optional. - In this example, we only accept a
UserEntity
as the input. If we wanted to create aUserDtoImpl
from some other input, we need to change
Let's look at a different approach, where the constructor takes each property as a separate argument.
class UserDtoImplSeparateArgs implements UserDto { constructor( public fullName: string, public permissions: UserDto['permissions'] | undefined, public username: string, ) { } } function mapUserEntityToUserDto(input: UserEntity, context: UserMappingContext): UserDto { return new UserDtoImplSeparateArgs( `${input.firstName} ${input.lastName}`, context.requesterAccess === 'admin' ? input.permissions : undefined, input.username, ); }
This has moved the mapping logic out of the constructor, into the code calling the constructor. To avoid duplication, we add a mapping function. (This could also be a static method on the class.)
Disadvantages:
- If we add a property to the output type/class, we have to add it to the constructor. It'll probably be easiest to add it to the end of the existing parameters. This will get tricky to read and write.
- Rather than passing individual args, we could pass one arg containing all the values. This seems silly; why would we create an object to create a different (but similar) object?
- You can't easily compose mapping logic. Inheritance is an option, but could become unwieldy, particularly if you want to omit properties.