Introduction

REST APIs play a crucial role in modern web development. What’s more, in many projects frontend and backend development are separate. It leads to problems such as unnecessary coupling, backward compatibility, or runtime correctness. In this article, we’re going to explore some of the TypeScript patterns for interacting with REST endpoints that address these issues.

Consuming REST Endpoints in TypeScript

Let’s consider the following example. Your application issues an HTTP GET call to an endpoint that returns a list of Users.

1
2
3
4
5
6
7
8
9
10
11
12
13
interface User {
id: string;
name: string;
isAdmin: boolean;
isMaintainer: boolean;
}

const fetchUsers = () =>
window
.fetch('http://my.app/api/users')
.then((response) => response.json())
.then((data) => data as User[]);
};

Somewhere else in the code, some component displays the details about each user. The same User type is being used to refer to the user object.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const [users, setUsers] = useState<User[]>([]);

return (
<div>
<ul>
{users.map((user) => {
<li>
{user.name}
{user.isAdmin && "admin"}
{user.isMaintainer && "maintainer"}
</li>;
})}
</ul>
</div>
);

Next, the team owning the endpoint decides to refactor it. They decide that instead of two boolean fields (isAdmin and isMaintainer), they will introduce a new enum role field.

1
2
3
4
5
6
7
8
9
10
11
const enum Role {
Regular = 0,
Maintainer = 1,
Admin = 2,
}

interface User {
id: string;
name: string;
role: Role;
}

As you update the types, you’ll notice compile errors in the component. This is because it is referring to the fields that no longer exist. In this simple case, you can just update the component and fix the errors. However, in a large application, the User type may be used in multiple places. Updating all of the usages may not be a simple task, as some of the usages may depend heavily on the particular shape of the object. And you may be in a hurry, as your manager forgot to tell you about the upcoming endpoint refactor, and now it’s needed desperately.

Introducing Data Transfer Objects

Let’s make our application less vulnerable to such changes. Let’s introduce a new UserDTO type.

1
2
3
4
5
6
interface UserDTO {
id: string;
name: string;
isAdmin: boolean;
isMaintainer: boolean;
}

DTO stands for Data Transfer Object. This is a pattern, widely used in the backend, in which you decouple types used for data transfer from the actual data model. In our example, the changes in the API will be reflected in the UserDTO type. However, thanks to the User type being completely separate, we won’t be forced to make any changes in it and consequently in its usages.

For this to work we need to define a mapping function that takes UserDTO and returns a User. We also need to call it in the fetchUsers so that the type returned by this function is still User[].

1
2
3
4
5
6
7
8
9
10
11
12
13
const userFromDto = ({ id, name, role }: UserDTO): User => ({
id,
name,
isAdmin: role === Role.Admin,
isMaintainer: role === Role.Maintainer,
});

const fetchUsers = () =>
window
.fetch("http://my.app/api/users")
.then((response) => response.json())
.then((data) => data as UserDTO[])
.then((users) => users.map(userFromDto));

This way we made the code less prone to sudden changes of API shape (at the cost of introducing some overhead). Once another API change is introduced, all you need to do is to adjust the UserDTO type and the userFromDto function. The change won’t affect any other layers of your app (unless you wish them to).

Backward Compatibility with Data Transfer Objects

One other advantage of this approach is that it is very convenient when your app needs to be backward compatible. In other words, depending on how your application is being deployed, it might be the case that backend and frontend won’t be updated at the same time. In such a case, the frontend needs to support both the old and the new version of the API at the same time. DTO pattern is helpful here because it introduces the concept of a mapping function (userFromDto) that encompasses the translation logic from the API world to your data model. This is exactly where you should put the code supporting both versions of the endpoint.

The simplest way to do this is to mark all of the fields in question as optional. Then userFromDto would check for the presence of these fields and assume that either role or both isAdmin and isMaintainer are defined.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
interface UserDTO {
id: string;
name: string;
role?: Role;
isAdmin?: boolean;
isMaintainer?: boolean;
}

const userFromDto = ({
id,
name,
role,
isAdmin,
isMaintainer,
}: UserDTO): User => ({
id,
name,
isAdmin: isAdmin ?? role === Role.Admin,
isMaintainer: isMaintainer ?? role === Role.Maintainer,
});

Another, more verbose (but less error-prone) way of doing this is to take advantage of Discriminated Union Types and type guards. This approach is particularly helpful when you’re working with complex types or when you need to support more than two API versions at the same time. It also makes it more explicit that two versions are supported and therefore makes it more likely that you will clean up this code once the need to support V1 disappears.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
interface User {
id: string;
name: string;
isAdmin: boolean;
isMaintainer: boolean;
}

interface UserDTOV1 {
id: string;
name: string;
isAdmin: boolean;
isMaintainer: boolean;
}

interface UserDTOV2 {
id: string;
name: string;
role: Role;
}

type UserDTO = UserDTOV1 | UserDTOV2;

const userFromDto = (dto: UserDTO): User => {
if ("role" in dto) {
// Thanks to the type guard, `dto` is typed as `UserDTOV2` here
return {
id: dto.id,
name: dto.name,
isAdmin: dto.role === Role.Admin,
isMaintainer: dto.role === Role.Maintainer,
};
} else {
// `dto` is typed as `UserDTOV1` which is compatible with `User` thanks
// to structural typing
return dto;
}
};

Typing Data Transfer Objects with TypeScript

The DTO type will usually have a lot of common fields with the domain type. It may seem very repetitive, especially in case of large types with tens of fields. TypeScript offers some mechanisms that may help with that.

1
type UserDTO = Omit<User, "isAdmin" | "isMaintainer"> & { role: Role };

Here we define UserDTO as a result of a transformation on the User type. More specifically, first we use the Omit built-in utlity type to remove two fields from User. Next, we intersect the result with an object type containing only one field - role.

The main advantage of this approach is the lack of repetitiveness. It doesn’t seem to be a problem in our little example but I’ve worked with huge types containing tens of fields where such an approach made more sense.

On the other hand, in this approach we don’t have full decoupling between these types - the DTO depends on the domain type. For example, if we added a new field to User, it will also appear in UserDTO, which is not desirable. Therefore, my advice is to use this approach with caution.

Runtime Correctness of DTO Types

As mentioned above, DTOs are particularly helpful in those projects where backend and frontend development are separate and you, as a frontend developer, don’t have much control over the APIs. In such environments, it sometimes may happen that you’re assumptions about the data structures returned by the APIs are wrong. For example, a backend team may unexpectedly change a field and forget to communicate it to you. This can also happen because of some bug on their side.

Such issues can be avoided in the world of DTOs. A DTO’s sole purpose is to describe the shape of the data returned from an endpoint. It’s very important that it does it correctly and that it’s kept in sync with reality. Let me describe two mechanisms that you can use to enforce this.

Runtime Assertions

As you know, TypeScript is just a language that gets compiled into JavaScript. The consequence of this is that all information about types gets erased at the moment of compilation. Therefore, there is no automatic way of checking whether objects actually fit into their type definitions at runtime.

This can however be achieved with some effort. The simplest approach would be to manually write the code that would check for existance of all fields declared in the type as well as verify their types.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const assertUserDto = (data: any): UserDTO => {
if (
"id" in data &&
typeof data.id === "string" &&
"name" in data &&
typeof data.name === "string" &&
"isAdmin" in data &&
typeof data.isAdmin === "boolean" &&
"isMaintainer" in data &&
typeof data.isMaintainer === "boolean"
) {
return data;
} else throw new Error("This is not a UserDTO!");
};

As you can see, this approach can be very tedious. Fortunately, there exist libraries such as io-ts that can help automate the task.

Note that runtime assertions introduce some performance overhead, especially when dealing with large arrays of objects. It’s advisable to disable runtime assertions in production environments.

DTO Type Generation

Another approach is to automatically generate DTO definitions based on endpoints. This can be easily achievable if the APIs you call use Swagger, which is the standard for describing and documenting APIs. Even if this is not the case, you can still look for tools that generate TypeScript types directly from the backend types (some time ago I created scala-ts for generating TS types from Scala types).

The only drawback of this approach is that it requires setting up some infrastructure that plugs into your build process and makes sure that you’re always working with the latest type definitions.

Conclusion

In this article, we discussed several patterns for dealing with REST endpoints in TypeScript.

Firstly, we introduced the concept of Data Transfer Objects that help you better manage changes in the endpoints and make your code less prone to changes. Secondly, we looked into how DTOs can help with ensuring backward compatibility. Lastly, we saw how to ensure that types are an accurate representation of the endpoints.


Did you like this TypeScript article? I bet you'll also like my book!
⭐️ Advanced TypeScript E-book ⭐️