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.
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 key
s 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.
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.
TypeScript explains mapped types like this:
A mapped type is a generic type which uses a union ofPropertyKey
s (frequently created via akeyof
) 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 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:
Event
type.Date
.string
.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 string
s.
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.