DEV Community

Nazeel
Nazeel

Posted on • Updated on

Writing type safe API clients in TypeScript

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.
  3. 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.
  4. 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 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.

npx openapi-typescript https://raw.githubusercontent.com/OAI/OpenAPI-Specification/main/examples/v3.0/petstore.yaml -o schema.ts
Enter fullscreen mode Exit fullscreen mode

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:

/**

* 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 {}
Enter fullscreen mode Exit fullscreen mode

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:

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

// Request shape

// Response shape

// API client

// API definitions
Enter fullscreen mode Exit fullscreen mode
  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.

// Request shape
type GetPetsRequest = paths['/pets']['get']['parameters']
type GetPetByIdRequest = paths['/pets/{petId}']['get']['parameters']
Enter fullscreen mode Exit fullscreen mode

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.

// Response shape

type GetPetsResponse = paths['/pets']['get']['responses']['200']['content']['application/json']
type GetPetByIdResponse = paths['/pets/{petId}']['get']['responses']['200']['content']['application/json']
Enter fullscreen mode Exit fullscreen mode

API Client

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


// 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
        }
    }
)
Enter fullscreen mode Exit fullscreen mode

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!

// 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
})
Enter fullscreen mode Exit fullscreen mode

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.

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

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
  2. Sample Code

Top comments (5)

Collapse
 
manchicken profile image
Mike Stemle

I'd recommend looking at the Fetch API, as the axios module is not a standardized interface and will result in larger running programs.

Also, can you help me understand how type-safety comes into play here in a way that can't be boiled down to simple validation? From what I gather of your article, ajv with a JSON schema would be just as "type-safe" since TypeScript doesn't actually affect types or type-safety under the hood.

Collapse
 
nazeelashraf profile image
Nazeel

Totally agree that the Fetch API is definitely the lightweight approach here, but unfortunately it does not support generics out of the box. This means instead of typing the response types at the api.ts level, we now have to type it inline at the response.json() as GetPetsResponse, which can be cumbersome if the same API call is used in different places. For more information, please see this GitHub thread. To circumvent this, we may have to write a custom wrapper around fetch to achieve generics.

Regarding ajv, I am not familiar with the library, but from a quick glance, it looks like something that would require manually typing (please correct me if I'm wrong) and also, it needs to be added as a dependency. The issue with this is that we would be technically be creating two sources of of truth for the API Schema, which could possibly backfire in the long run. It also seems to be doing runtime validations, but in this article, we shift left the type safety into the dev/coding phase, assuming that the OpenAPI spec is the single source of truth, and if APIs are versioned correctly (ie, v1, v2, etc), we wouldn't need to go back and change the schema very often. However, I can see the flexibility it provides on the server side with custom validation when we expect APIs, which are likely external, to return malformed values. For internal APIs though, we should be able to get away with the approach mentioned in the article.

Thanks for the comment, I hope I was able to answer it!

Collapse
 
harsh2909 profile image
Harsh Agarwal

This is a great tool for generating TypeScript based Types.
You might wanna check out kiota by Microsoft.
github.com/microsoft/kiota

This will allow you to generate API clients for most of the common languages.

Collapse
 
nazeelashraf profile image
Nazeel • Edited

Although it seems like you need to add deps, which openapi-typescript does not need since we use npx, this looks super cool and I'll definitely be checking it out. Thanks for pointing it out!

Collapse
 
robinamirbahar profile image
Robina

Nice