Typesafe API clients for web-frontends

July 03, 2022

The pain of missing types

Once in my career, I joined a team that was working on rather large JavaScript based web-frontend. The fact that there are no type annotations in JavaScript made it very difficult to understand the project in detail. If complex objects are passed around between functions, I often had to trace the data back to the originating backend call. Then I have to call the backend myself in order to see what the data looks like and of course, I just got an example response. I could never be sure that there weren’t any optional fields missing in the response.

At some point, we used OpenAPI/Swagger to generate a UI, and you could see the actual datastructures on that page… So far so good.

Using TypeScript

In the next project, we decided to use TypeScript from the start. We defined a function for each endpoint, which in turn was using axios to access the backend. This function was typed with the proper response. Like in this example:

import axios, { AxiosResponse } from "axios"

interface TodoResponse {
  id: number
  name: string
  description: string
}

interface TodoRequest {
  name: string
  description: string
}

interface TodoListResponse {
  todos: TodoResponse[]
}

export async function addTodo(todo: TodoRequest): Promise<TodoResponse> {
  const response = await axios.post<TodoRequest, AxiosResponse<TodoResponse>>(
    "/api/todos",
    todo
  )
  return response.data
}

This approach worked pretty well, but there was one annoying issue: Every time the backend created or changed an endpoint, we had to handcraft the types for the backend response.

What if we could generate this code automatically?

OpenAPI to TypeScript

OpenAPI cannot only be used to create fancy, interactive documentation. It can also generate client code (even server code) for several programming languages. The official tool for this is the OpenAPI generator. While this tool is certainly mighty and versatile, it has a problem: It is written in Java.

Since the whole ecosystem of frontend builds is written in Node.js, it is easier and faster to use a Node.js based tool.

After some research, I came up with three candidates, that I found useful and that I will now present in more detail.

Avoiding vendor lock-in

Don’t fall for the temptation to use the generated in your components directly. If you decide to use a different library, you will have a lot of work fixing your code. I usually still write an addTodo function like in the example above. I still define the parameter and return-types for that function. Then I call the generated client from this function.

Using the generated code is just a confirmation that my frontend models match the actual backend. The benefits:

  • If you change the backend, you can regenerate your code and then adjust the addTodo function to transform the data to the frontend model.
  • If you decide to refactor you frontend model, you can use the refactoring functions of you IDE instead of having to look at 2500 compile errors.
  • If you decide to use a different code-generator, you just need to adjust the code in the one folder.

Code generators

In the following examples, I use a very small OpenAPI-spec as source. It contains a single endpoint that can receive a JSON like { "password": "test" } in the request body and responds with something like {"encryptedPassword": "dqkdnjqwejnedlqwe, "adminPasswordConfigKey": "some.key" }

{
  "openapi": "3.0.3",
  "info": { "title": "Gachou Backend", "version": "0.0.1" },
  "paths": {
    "/api/encryptPassword": {
      "post": {
        "operationId": "encryptPassword",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/PasswordEncryptionRequest"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "On success",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/EncryptedPasswordResponse"
                }
              }
            }
          }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "EncryptedPasswordResponse": {
        "required": ["encryptedPassword", "adminPasswordConfigKey"],
        "type": "object",
        "properties": {
          "encryptedPassword": { "type": "string", "nullable": false },
          "adminPasswordConfigKey": { "type": "string", "nullable": false }
        }
      },
      "PasswordEncryptionRequest": {
        "required": ["password"],
        "type": "object",
        "properties": { "password": { "type": "string", "nullable": false } }
      }
    }
  }
}

The source of the examples can be found under https://github.com/nknapp/typesafe-api-client-examples

openapi-client-axios

The library openapi-client-axios was my preferred choice until recently.

The pros

It uses and exposes axios to run http-requests, which mean that you can use axios-features, like “interceptors”. This allows you to do perform custom error handling and authentication at a single point in your code.

The cons

The downside of this library is, that it is a one-man project. There is only a single author on npm and not issues get fixed very quickly, including my issue from Dec 2, 2020.

The library currently does not work with vite, because of this issue.

Using openapi-client-axios

To generate types you can install openapi-client-axios-typegen, which allows you to generate typings by running

npx typegen ./openapi.json > client.d.ts

After that, you can use those types:

import OpenAPIClientAxios, {OpenAPIV3} from 'openapi-client-axios';
import spec from './openapi.json'
import {Client} from "./client";

const api = new OpenAPIClientAxios({definition: spec as OpenAPIV3.Document});
const client = api.initSync<Client>();

client is now has functions for all operation-ids found in the OpenAPI-spec, and with because of generated typings, you get type-checks and auto-completion:

autocompletion-example

oazapfts

The library oaszapfts is currently my favorite, and I am using in my private project Gachou.

The pros

  • Multiple collaborators (four, as of today) have been publishing this project on npm. This looks like less risk of being abandoned, but it is far away from other open-source projects that have large teams of maintainers.
  • It works with vite.
  • It generates tree-shakable code.

The cons

I don’t see any real drawbacks at the moment. The library uses fetch directly, so you cannot use axios-features. But you can configure global headers via the exposed defaults-objects of the module. You can even specify a custom fetch function, which allows you to wrap the browser’s fetch with your own wrapper.

You cannot have multiple instances of the same api using different defaults, though.

I will show one other slight con in a moment.

Usage

After installing the module, you can run

npx oazapfts ./openapi.json oazapfts-client.ts

The client can simply be used:

oazapfts example

In this example the ok()-method is a wrapper that throws an error if the response is anything other than an HTTP-2xx response code.

The slight drawback of this library is that I cannot name my function encryptPassword, because that is already the operation id and therefore the name of the generated function. (Yes, I could use import ... as to map the function name, but I am lazy and the IDE adds the import for me, so I don’t want to do a lot of work changing it.)

You can globally set request-headers

import { defaults } from "./oazapfts-client"

defaults.headers = {
  authorization: "Bearer myToken",
}

openapi-typescript-codegen

I have come across this library when looking for TypeScript generators for the first time. I abandoned it then, because we were usually using axios and openapi-typescript-codegen didn’t support it back then (issue#400). This issue seems to be resolved now, so if you like, try it out.

The pros

  • Can generate code for “fetch”, “xhr”, “node”, “axios” and “angular”
  • Output can be tweaked by some cli options

The cons

  • The library seems to be maintained by a single person only.
  • Current version is 0.23.0, which means the maintainer does not consider the API stable yet.

Apart from that, I cannot say much, because I haven’t used the library yet.

Usage

Running the command

openapi -i ./openapi.json -o __generated__

will create multiple files in the __generated-directory. The entrypoint is exposed in the __generated__/index.ts file. You can use it like

import { DefaultService } from "./__generated__"

async function encryptPassword() {
  const response = await DefaultService.encryptPassword({ password: "test" })
  return response.encryptedPassword
}

All in all, the generated code is less compact than the code of oazapfts, but it is certainly worth a look, if you are looking for such a tool and the other two don’t fit your needs.

Conclusion

There are several tools available to convert OpenAPI specifications to Typescript clients. If you use such a tool, you will get auto-completion and type-checks for request- and response-body properties, for query-parameters and for operation-ids.

Be careful not too lock yourself in to one tool, but rather add another layer of indirection. It will helps you with changes later-on.

I have presented three of those tools here and my favorite currently is oazapfts, It generates compact, tree-shakable code and while still being configurable. It is not possible to create multiple instances of a service, but I also cannot imagine a use-case for such a feature.


Profile picture

Written by Nils Knappmeier who has been a full-time web-developer since 2006, programming since he was 10 years old.