Writing type safe API clients in TypeScript
Tags: typescript, openapi, webdev, api
Published: 2024-01-06 09:54:25

Writing API integrations can be a daunting task. But it does not need to be. How many times have you had to deal with these problems?

1. You want type safe API calls with typified response types and request types.
2. You have manually typed your API response structures in the past and found it to be a cumbersome effort which is also prone to errors.
3. You do not want to deal with the complexity of setting up and maintaining something like GraphQL for making API integration simpler.
4. Your APIs are microservices and your frontend API layer is not able to keep up with the various changes in each microservice.

If you resonate with any of the above points, then you’re in for a treat. In this blog post, I will try to show you a way to build type safe API clients on any TypeScript frontend, CLI or SDK application.

In order to provide a middle ground between the chaos that is writing your own types and the complexity that is to set up something like GraphQL, we'll be using `openapi-typescript`. The API client I've chosen here is `axios`, but feel free to use any API client that allows you to define response types as generics.

## So what is `openapi-typescript` anyway?

`openapi-typescript` is a TypeScript type generator that takes an openapi spec file and generates a TypeScript schema for you. This is advantageous in many ways:
1. It prevents any sort of margin for human error if you were to, say, write the types on your own.
2. It saves a _lot_ of time.
4. You don't have to deal with setting up and maintaining something like GraphQL or tRPC, which can both be paradigm shifts for the team or in some cases, over-engineering.
5. It requires no additional dependencies.

## Getting Started

To achieve this, I'll be using a simple typescript project that has three files, namely:
1. `main.ts`
2. `api.ts`
3. `schema.ts` (this is generated by `openapi-typescript`)

And I'll be using the OpenAPI [Pet Store](https://github.com/OAI/OpenAPI-Specification/blob/main/examples/v3.0/petstore.yaml) spec file as an example.

Of course, you'll also need a working `node` installation.

## Generating the schema

Run the following command to generate the schema from the pet store api spec.

```bash
npx openapi-typescript https://raw.githubusercontent.com/OAI/OpenAPI-Specification/main/examples/v3.0/petstore.yaml -o schema.ts
```

This will generate the `schema.ts` file which can then be used in our API file. Note that I opted for using the URL of the spec file, you can choose to reference a local file as well by providing the relative path from where the command is being run.

The file will look something like this:

```ts
/**

* This file was auto-generated by openapi-typescript.

* Do not make direct changes to the file.

*/

export interface paths {}

export type webhooks = Record<string, never>;

export interface components {}

export type $defs = Record<string, never>;

export type external = Record<string, never>;

export interface operations {}
```

There are 3 core exported interfaces that we are interested in. Let's break each one down:
1.  `components` - The gold we need resides here which are the schemas that are used by the API, including their shapes and the type of each attribute.
2. `operations` - Operations refer to each of the possible operations defined in the API spec, their query parameters (if any) and their possible response types (based on success or error statuses), all referencing the the corresponding `components` entry based on the operation.
3. `paths` - This contains all the API endpoints that are exposed as per the API spec, grouped by the endpoint and including the REST operations allowed (`GET`, `POST`, etc) and links them to the corresponding `operations`. This will be the most used exported module of this file.

Alright, now that we've got the schema out of the way, let's look at how we can bring this into our APIs.

## Integrating with the API layer

First, let's import our axios, schema modules and split the `api.ts` file into 3 sections:

```ts
import axios from 'axios'
import { paths } from './schema'

// Request shape

// Response shape

// API client

// API definitions
```

1. The request shape section has all the types that are required to make an API call for a specific operation.
2. The response shape section has all the types associated with the response of the API
3. In the API client section, we define the API client and other client related code such as interceptors to add auth headers, etc.
4. In the API definitions, we define each API, with the help of all the above ingredients.

### Request shape

The request shape can be derived from the `paths` bit of the schema. Let's add in the request shape for all the APIs.

```ts
// Request shape
type GetPetsRequest = paths['/pets']['get']['parameters']
type GetPetByIdRequest = paths['/pets/{petId}']['get']['parameters']
```

### Response Shape

Similarly, the response shape can also be derived from the `operations` bit of the schema. Let's add in the response shape for all the APIs.

```ts
// Response shape

type GetPetsResponse = paths['/pets']['get']['responses']['200']['content']['application/json']
type GetPetByIdResponse = paths['/pets/{petId}']['get']['responses']['200']['content']['application/json']
```

### API Client

Here we define the actual API client which will be used for making all of the calls to the petstore service:

```ts

// API client

const petAPIClient = axios.create({
    baseURL: 'https://api.petstore.com/'
})

petAPIClient.interceptors.request.use(
    async config => {
        return {
            ...config,
            headers: {
                'Authorization': 'Bearer abcd'
            } as any
        }
    }
)
```

### API Definitions

And finally, let's use all of the above ingredients to cook up a nice little API meal for our application to consume!

```ts
// update the import for the Method type
import axios, { type Method } from 'axios'

// API definitions

export const getPets = ({
    params
} : {
    params: GetPetsRequest
}) => petAPIClient.request<GetPetsResponse>({
    method: 'GET' as Method,
    params: {
        limit: params.query?.limit
    }
})

export const getPetById = ({
    params
} : {
    params: GetPetByIdRequest
}) => petAPIClient.request<GetPetByIdResponse>({
    url: `${params.path.petId}/`,
    method: 'GET' as Method
})
```


Voila! You've successfully set up the API layer of the frontend application. Onward to integration!
## Integrating with the application

Integrating with the application is as simple as declaring the API call using a dedicated function.

```ts
import { getPets } from "./api"

const fetchAllPets = async () => {
    try {
        const result = await getPets({
            params: {
                query: {
                    limit: 1
                }
            }
        })
        console.log(result.data[0].id)
    } catch (err) {
        console.log(err)
    }
}
```

If you're tagging along with me, you will notice that all the intellisense goodness just works when you try to make an API call! Here is a gif to demonstrate this.

<img src="https://i.imgur.com/aerYdPD.gif" alt="demonstration video" />

Isn't that a sight to behold?

## Conclusion

What we've achieved here are type safe API calls in the frontend with a very minimal setup and no additional dependencies. Any changes in the schema will cause TypeScript compiler to scream wherever the changes are breaking so all nooks and crannies are covered when something changes in the API spec!

Of course, there are some caveats with this approach:
1. You need to be in constant sync with the backend engineer to make sure that any changes in the API spec also need to be communicated well in advance so you can coordinate the changes in the frontend. However, one argument to this is any breaking changes need to be versioned by the API and a new spec file is to be generated so that we can repeat the process and upgrade the frontend to the latest API gracefully.
2. You'll need to watch out for any changes made to the schema file in pull requests to make sure that it is not changed unintentionally. Obviously, any changes made would also mean corresponding invocations and unit tests would break the TS compiler, so this stands at less of a risk than the above.

Links & References:
1. [OpenAPI TypeScript](https://openapi-ts.pages.dev/)
2. [Sample Code](https://github.com/nazeelashraf/blog-examples/tree/main/blog/001)