default

"Exhaustive", type-safe object mapper. Use this when you want to ensure every property on your output object has been mapped.

Examples

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 a UserDtoImpl 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.

Classes

c
AsyncObjectMapper<
TInput extends object,
TOutput extends object,
TContext extends object | undefined = undefined
>
(schema: AsyncObjectMapperSchema<TInput, TOutput, TContext>)

Convert from one type of object to another.

c
ObjectMapper<
TInput extends object,
TOutput extends object,
TContext extends object | undefined = undefined
>
(schema: ObjectMapperSchema<TInput, TOutput, TContext>)

Convert from one type of object to another.

Interfaces

I
asyncTypes.AsyncMapperFunction

A function that takes some input object, and an optional context object, and returns a promise of an output. This is used as part of an AsyncObjectMapperSchema.

I
asyncTypes.AsyncObjectMapperFunction

A callable function, equivalent to calling AsyncObjectMapper#map. It also exposes AsyncObjectMapperFunction#schema as a readonly property.

I
types.MapperFunction

A function that takes some input object, and an optional context object, and returns an output. This is used as part of an ObjectMapperSchema.

I
types.ObjectMapperFunction

A callable function, equivalent to calling ObjectMapper#map. It also exposes ObjectMapperFunction#schema as a readonly property.

Namespaces

N
asyncTypes

These types are used by the AsyncObjectMapper. Here's the magic which makes object mapper schemas type safe and complete. You generally won't need to make use of these types within your own code; just ObjectMapper should be enough.

N
types

These types are used by the ObjectMapper. Here's the magic which makes object mapper schemas type safe and complete. You generally won't need to make use of these types within your own code; just ObjectMapper should be enough.

Type Aliases

T
types.AllowInputKeyIfInputCanExtendOutput<TInput, TOutputValue> = [TInputKey in keyof TInput]: TInput[TInputKey] extends TOutputValue ? TInputKey : never[keyof TInput]

Given some object TInput, and some value TOutputValue, allows any key of TInput that can be assigned to TOutputValue.

T
types.ExactReturn<T> = T extends readonly unknown[] ? number extends T["length"] ? T extends (infer E)[] ? ExactReturn<E>[] : readonly ExactReturn<T[number]>[] : [K in keyof T]: ExactReturn<T[K]> : T extends object ? T & { readonly [ExactReturnKeys]?: keyof T; } : T

Brand an object type with a phantom property whose type is keyof T, so that a wider key set is NOT assignable to a narrower one. This makes mapper-produced object return types invariant in their key set: a mapper for a superset type can no longer be substituted where a mapper for a subset is expected.

T
types.ObjectMapperSchema<
TInput extends object,
TOutput extends object,
TContext extends object | undefined = undefined
>
= [TOutputKey in keyof TOutput]-?: MapperSchemaValue<TInput, TOutput, TContext, TOutputKey>

An object, where every property name must match a property name in the desired output type. Every property value must be a MapperSchemaValue.

T
types.OptionalArgIfUndefined<T> = T extends undefined ? T | void : T

An ObjectMapper can take an optional context. The context type is defined when you instantiate an ObjectMapper. If the context type is undefined, rather than passing undefined every time, you can just omit the property. (You can still pass undefined, for parity with the mappers that require a context.)

Variables

v
mapFrom: { constant<TValue>(
this: void,
value: TValue
): () => TValue; omit(this: void): OmitProperty; null(this: void): null; pick<TKey extends PropertyKey>(
this: void,
key: TKey,
...keys: TKey[]
): [K in TKey]: K; undefined(this: void): undefined; }

Provides convenience functions for object mappers.

v
mapFromAsync: { constant<TValue>(
this: void,
value: TValue
): () => Promise<TValue>; omit(this: void): Promise<OmitProperty>; null(this: void): Promise<null>; undefined(this: void): Promise<undefined>; }

Provides convenience functions for object mappers.