In this post we’re going to build upon the idea presented in the first post of the series. That post introduced the concept of Data Transfer Objects - types that represent the responses (or request bodies) used when consuming backend endpoints. Importantly, DTOs should be used for this purpose only - they should be converted to domain types before being passed to UI components or business logic.

Creating DTOs from the scratch, as well as maintaining them, is a tedious and error-prone process. I saw many production bugs caused by a mismatch between the type describing a response of some endpoint and what this endpoint actually returned.

The earlier article already mentioned that it is desirable to eliminate the human factor from the process of creating and maintaining DTO types. This post is going to elaborate on this idea.

OpenAPI

OpenAPI Specification is a way of describing APIs in a standardized format that can be easily parsed. With OpenAPI you can list all the endpoints in your API, describe the shape of requests and the responses, list supported HTTP methods, etc. Given such specification you can generate documentation, client code or… types.

In order to to generate TypeScript types from OpenAPI, you will need a tool that can read and parse the specifications and turn them into type definitions. The one which I’ve been using and have been happy with is called openapi-typescript.

Type generation with openapi-types

Let’s see openapi-typescript in action. Here you can find a repository with an implementation of a simple React app that fetches and displays data from Spotify.

The app lets you:

  • search Spotify albums
  • view details of a particular album
  • view the list of playlists for the current user
  • create a new playlist

Spotify exposes an OpenAPI specification under this URL. Our project sources contain a file called spotify.d.ts which is based on this specification. Here is the command used to generate this file:

1
npx openapi-typescript https://developer.spotify.com/reference/web-api/open-api-schema.yaml -o src/spotify.d.ts

The project is structured in the following way:

  • types/spotify.d.ts - type definitions generated by openapi-types
  • types/dto.ts - type definitions for Data Transfer Objects - aliases for selected types from spotify.d.ts defined purely for convenience
  • types/domain.ts - type definitions for Domain Types
  • services/conversion.ts - functions for converting DTOs to Domain Types and the other way (if needed)
  • services/api.ts - functions for making server requests - they only operate with DTO types
  • services/auth.ts - authentication code, not relevant to this article
  • components/* - React components that implements data fetching and the UI; they use api.ts functions to fetch DTOs and then convert them to Domain Types with conversion.ts functions

First, let’s take a look at the DTO type definition for response types:

1
2
3
4
5
6
7
import { paths } from "./spotify";

export type AlbumSearchResponseDto =
paths["/search"]["get"]["responses"]["200"]["content"]["application/json"];

export type AlbumDetailsResponseDto =
paths["/albums/{id}"]["get"]["responses"]["200"]["content"]["application/json"];

As you can see, we define the DTO types using the paths type imported from the generated file. The types form a nested structure allowing different response types for different HTTP methods, status codes, content types, etc. At the first level, the type is indexed by string literal representing all the available paths. In this particular example, we’re extracting the type for a JSON response to a successful GET request sent to the “/albums/{id}” endpoint.

You may point out that this type is rather verbose. There is a way to shorten it, although it’s undocumented. You can use the SuccessResponse utility type from the openapi-typescript-helpers package. With it, the type argument would look like this:

1
2
3
export type AlbumSearchResponseDto = SuccessResponse<
paths["/search"]["get"]["responses"]
>["application/json"];

Above DTO definitions are used in the api.ts file where they are passed as type arguments to axios calls:

1
2
export const getAlbum = async (id: string) =>
await axios.get<AlbumDetailsResponseDto>(`/albums/${id}`);

Let’s also take a look at an example of a request DTO type:

1
2
3
4
5
6
7
8
9
10
11
12
13
import { OperationRequestBodyContent } from "openapi-typescript-helpers";

export type CreatePlaylistRequestDto = OperationRequestBodyContent<
paths["/users/{user_id}/playlists"]["post"]
>;

export const createPlaylist = async (userId: string, name: string) => {
const playlist: CreatePlaylistRequestDto = {
name,
public: false,
};
return await axios.post(`/users/${userId}/playlists`, playlist);
};

Here we again leverage openapi-typescript-helpers for a terser type definition. As you can see, we can extract both request and response types from the the OpenAPI-generated definitions. In fact, there is much more information encoded in these definitions - e.g. names of query parameters, path parameters, types of all possible error responses, etc. I encourage you to browse the spotify.d.ts file and find out on your own.

Consuming endpoints with openapi-fetch

Now, let’s go one step further. If you look at the code in api.ts, you may notice that it is kind of schematic. Every function here consists of a single axios call. Its main role is to match the correct DTO type to the endpoint url.

1
2
export const getAlbum = async (id: string) =>
await axios.get<AlbumDetailsResponseDto>(`/albums/${id}`);

For example, getAlbum function asserts that the type of the response returned from /albums/${id} is AlbumDetailsResponseDto. This assertion is manual and therefore error-prone. What if the type system could figure it out on its own?

Enter openapi-fetch - a library built on top of openapi-types which exposes functions for making HTTP requests that can infer the type of the request, response, query params, etc. based on the provided URL. Let’s see it in action.

First, we need to create a client object. We provide the paths type as the type argument. This type is generated by openapi-types and is responsible for the magic of figuring out types based on URLs.

1
2
3
4
const spotifyClient = createClient<paths>();

export const getAlbum = async (id: string) =>
spotifyClient.GET("/albums/{id}", { params: { path: { id } } });

Next, let’s look at an openapi-fetch variant of the above getAlbum function. There are several cool things about it:

  • "/albums/{id}" string is type-checked; if we made a typo, the compiler would complain:
    1
    Argument of type '"/albumz/{id}"' is not assignable to parameter of type 'PathsWithMethod<paths, "get">'.ts(2345)
  • path.id parameters is required - the compiler will therefore make sure that we provide it.
  • Most importantly, the return type of this function is inferred correctly:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    const getAlbum: (id: string) => Promise<{
    data: {
    album_type: "album" | "single" | "compilation";
    total_tracks: number;
    available_markets: string[];
    external_urls: {
    spotify?: string | undefined;
    };
    href: string;
    id: string;
    ... 6 more ...;
    uri: string;
    } & {
    ...;
    };

Let’s take at another example where the library correctly figures out the request type.

1
2
3
4
5
6
export const createPlaylist = async (userId: string, name: string) =>
spotifyClient
.POST("/users/{user_id}/playlists", {
params: { path: { user_id: userId } },
body: { name, public: false },
});

In this example we need to provide both the user_id path param to identify the user for whom the playlist will be created as well as the body of the request containing the definition of the playlist.

Check out this link to see the full sources of the openapi-fetch based implementation of the Spotify client app.

openapi-fetch is very convenient to work with. You can easily customize it (see the above repo for an example of adding authorization) or build wrappers around it so that it’s usable with more advanced data fetching solutions such as react-query or rxjs. I’ll look into this in more detail in a separate article.

Summary

In this article we saw how TypeScript can be used to dramatically improve safety of calling server endpoints. If you think about it, with the manual approach to typing DTOs, you can have a perfectly typed codebase but can still see runtime errors related to backend calls. This is because when manually typing DTOs, you’re actually making implicit type assertions. The tools described above help you mitigate this risk almost completely.


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