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 byopenapi-types
types/dto.ts
- type definitions for Data Transfer Objects - aliases for selected types fromspotify.d.ts
defined purely for conveniencetypes/domain.ts
- type definitions for Domain Typesservices/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 typesservices/auth.ts
- authentication code, not relevant to this articlecomponents/*
- React components that implements data fetching and the UI; they useapi.ts
functions to fetch DTOs and then convert them to Domain Types withconversion.ts
functions
First, let’s take a look at the DTO type definition for response types:
1 | import { paths } from "./spotify"; |
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 | export type AlbumSearchResponseDto = SuccessResponse< |
Above DTO definitions are used in the api.ts
file where they are passed as type arguments to axios
calls:
1 | export const getAlbum = async (id: string) => |
Let’s also take a look at an example of a request DTO type:
1 | import { OperationRequestBodyContent } from "openapi-typescript-helpers"; |
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 | export const getAlbum = async (id: string) => |
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 | const spotifyClient = createClient<paths>(); |
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
15const 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 | export const createPlaylist = async (userId: string, name: string) => |
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 ⭐️