Me

Lodybo

Using Conditional Mapped Types when interfacing with REST API's

February 7, 2022February 7, 2022About 4 minutes

One of the biggest advantages of TypeScript, at least for me, is the ability to model stuff. You can write interfaces that represent your business requirements, your data structures, and your REST API. You can type what the data part of your request will be, but also what the response will return.

It can happen, however, that the model for your REST API is slightly different than the model you're using in your application. I recently came across such a use case, because I had a data model that made use of Date objects. The REST API expected those Date objects to be converted to ISO strings.

So, what to do? While it's possible to write two similar interfaces, I wanted to explore another possibility. I wanted to write the interface for the internal model (the one in use in my application), and write a type for the REST API.

That way, I'd only have to maintain one interface.

Writing our application model

So let's start with a fictional application which displays a list of events. We can write an interface for a single Event which we can then use throughout our application.

interface Event {
  id: number;
  name: string;
  maxAttendance: number;
  address: EventAddress;
  startDate: Date;
  endDate: Date;
}

So in our model, we have some basic metadata like an id (handy for React keys for example), the name of the event, the maximum attendance and the address (which is specified in another interface but that's outside the scope of this article).

The important parts are the startDate and endDate.

Personally, I prefer to use full Date objects in my models, because they are easy to pass around and manipulate whenever you need. Using a library like date-fns, we can format this in a readable output for the user, but we can also easily manipulate a Date by adding or subtracting days, months or years.

A requirement for our fictional application will be that someone can create an Event from the application and store it in the database. The server will accept a request object that conforms to the following interface:

interface RequestData {
  id: number;
  name: string;
  maxAttendance: number;
  address: EventAddress;
  startDate: string; // In the form of an ISO-string
  endDate: string; // In the form of an ISO-string
}

It's essentially the same form as our Event interface, with the exception that the startDate and endDate properties are now a string. We could write two interfaces and keep them up to date. So whenever we'd have another property in an event, like a masterOfCeremony: Person;, we would need to add it to both interfaces.

Let's see if we can create a RequestData type without duplication.

Conditional Mapped Types

Okay, granted, I don't know if "conditional mapped types" really is a terminology within TypeScript. I borrowed heavily from the TypeScript documentation about mapped types.

Before we dive into this, let me first explain what mapped types and conditional types are.

Mapped types

TypeScript explains mapped types like this:

A mapped type is a generic type which uses a union of PropertyKeys (frequently created via a keyof) to iterate through keys to create a type.

So a mapped type is a type which can loop over the keys in a property, perform operations and return a new type.

// Set all types in SomeOtherType to strings
type StringifiedType<SomeOtherType> = {
  [Property in keyof SomeOtherType]: string;
};

Using a mapped type, we can construct a new type based on another type which (in this case) will have all its members set to string.

type MathConstants = {
  pi: number;
};

type StringifiedMathConstants = StringifiedType<MathConstants>;

const constants: MathConstants = {
  pi: 3.1415;
};

const stringifiedConstants: StringifiedMathConstants = {
  pi: '3.1415';
};

What just happened? We used our stringifiedType type to create a new type. That new type has all its properties set to type string. So, how does stringifiedType know which properties to iterate over? It knows because it is a generic type and expects to be provided a type for it to loop over.

The TypeScript documentation goes over a lot more stuff when it comes to mapped types, so be sure to read that if you really want to get to grips with it.

Conditional types

Conditional types are types that are expresses as a ternary statement.

interface SomeType { }

interface OtherType extends SomeType { }

type OnlyStrings = OtherType extends SomeType ? string : never;
//   ^^^^^^^^^^^ type OnlyStrings = string;

type OnlyStrings = OtherType extends RegExp ? string : never;
//   ^^^^^^^^^^^ type OnlyStrings = never;

We can use it to check whether a type is assignable to another type. The documentation shows a lot of use cases like simplifying overloads. In our case, we will use it to loop over our the properties in our mapped type and perform some operation.

So let's take the Event type we defined earlier and write a mapped conditional type which will:

  • Loop over every property in our Event type.
  • Check if a property is of type Date.
  • If so, set the type to string.
  • If not, leave the type as is.

We'll end up with the following type:

type ConvertDatesToStrings<Type> = {
  [Property in keyof Type]: Type[Property] extends Date ? string : Type[Property];
}

We can use this type to create the body of our request before we sent it to our REST API. The TypeScript compiler will throw an error if we use Date objects instead of strings.

const birthdayParty: Event = {
  id: 1;
  name: 'Birthday party';
  maxAttendance: 50;
  address: partyAddress; // Defined elsewhere
  startDate: new Date(2022, 0, 1, 20, 0, 0),
  endDate: new Date(2022, 0, 2, 2, 0, 0),
};

const requestBody: ConvertDatesToStrings<Event> = {
  ...birthdayParty,
  startDate: birthdayParty.startDate.toISOString(),
  endDate: birthdayParty.endDate,
//^^^^^^^ Type 'Date' is not assignable to type 'string'.(2322)
};

We can send the requestBody as part of a fetch statement towards the back-end. A big advantage is that any change to the Event interface will also be reflected in the requestBody. This means that we only need to maintain one interface.