End-to-end type safety for the REST of us.

End-to-end type safety for the REST of us.

Just because we're dealing with 3rd party REST APIs doesn't mean we have to suffer.

📅 March 1, 2024 (5 months ago) - 📖 9 min read - 👀 276 views

In modern web development, using TypeScript efficiently is not just a luxury; it's a requirement for writing reliable apps. With well-written types, we get a great Developer Experience (DX), and a great DX allows for the fast building and maintaining of great products. However, a major challenge is often the need to write these types, and dealing with the mental load of ensuring they work together in addition to managing business logic can significantly slow us down.

To address this issue, you may be familiar with tools like GraphQL Codegen or trpc. Both tools enable us to automatically generate TypeScript types from the backend for use on the frontend.

  • With GraphQLGraphQL, we use the schema as a source (GraphQL checks at runtime that the types sent and received match the schema),
  • And with tRPCtRPC, we directly build fully-typed REST endpoints (trpc also performs runtime data validation).

But sometimes, we're faced with 3rd party APIs, or a legacy REST API that isn't easily changed. In these scenarios, we end up writing types on the frontend, essentially assuming the type of parameters and responses and hoping for the best.

BUT here's the good news: If the API we're working with has a Swagger (OpenAPI) definition, we can actually generate types from it! 🎉

Let's explore how the REST of us can also enjoy a great DX.

Generating Types

API

Assuming we're consuming an API that provides exchange rates from a base currency with a GET endpoint:

Forbidden error response

Adding openapi-typescript to Your Project

In a TypeScript project, we can simply install the openapi-typescript package npm i -D openapi-typescript@next.

ℹī¸

You can also explore the example code by yourself.

We can then run npx openapi-typescript $YOUR_API_FILE_OR_URL -o ./openApi.ts to generate the types.

Forbidden error response

By directing this command at a swagger.json file, whether it's in the same repo or behind a URL, we generate a TypeScript file (here called openApi.ts) containing types that mirror your API's structure.

Fetching Data with the Generated Types

Using openapi-fetch

Now, we can easily consume this API with the generated types using openapi-fetch (docs), which utilizes axios under the hood.

npm i openapi-fetch

We just need to create a client instance that takes the paths type as a generic:

import createClient from 'openapi-fetch'
import type { paths } from '../openApi'
 
const { GET } = createClient<paths>({
  baseUrl: 'https://api.example.com/v1/',
})

This setup gives us autocompletion when fetching data, and the returned data is also typed:

Forbidden error response

A Custom Alternative

You might now be wondering if it's possible to use tanstack-query or if this approach works for client and server-side components in Next.js.

It's actually not that hard to build a custom client implementation based on the types we generated.

Consider the custom function, apiCall, available in the repository. Before using it, install our chosen client (axios) and query-string: npm add axios query-string.

Copy the apiCall function file into your project and use it as follows:

import { apiCall } from './apiCall'
import { paths } from './openApi'
 
const exchangeRate = await apiCall('/currency/exchangerate', 'get', {
  base: 'USD',
})
ℹī¸

One difference to note, in this case we don't return an error object when something goes wrong, but instead throw an error as we want react-query or Next.js server side error handling(or any other framework's) to take care of the error.

The same way that openapi-fetch does, we have a fully typed response, but we now own the code, so we can customise it.

Example Auth

For example, we may want to pass a JWT in our headers. But in most modern frameworks (Next.js, Remix, etc) we either are running the code in the browser, or on a server. Therefore, the way we provide the token might differ based on the platform.

To solve this, we can add a custom parameter that will take a function that returns the token, and simply pass a different function based on which side of the web we are in:

const path = '/currency/exchangerate'
 
// Client side
export const useGetExchangeRate = () => {
  const { getToken } = useAuth()
 
  return useQuery({
    queryKey: [path],
    queryFn: async () =>
      apiCall(getToken, path, 'get', {
        query: {
          base: 'EUR',
        },
      }),
  })
}
 
// Server side
export async function getExchangeRate() {
  const { getToken } = auth()
 
  return await apiCall(getToken, path, 'get', {
    query: {
      base: 'EUR',
    },
  })
}

As simple as that. Ez.

"Yes yes, but.. I still have to create types to pass the correct values to these two functions"

Well.. not quite. There actually are a couple of helpers that we can use that will help with that:

const path = '/currency/exchangerate'
 
type ExchangeRateQuery = RequestParams<typeof path, 'get'>['query']['base']
 
// Client side
export const useGetExchangeRate = (baseCurrency: ExchangeRateQuery) => {
  const { getToken } = useAuth()
 
  return useQuery({
    queryKey: [path],
    queryFn: async () =>
      apiCall(getToken, path, 'get', {
        query: {
          base: baseCurrency,
        },
      }),
  })
}
 
// Usage in client-side components
const { data: exchangeRate } = useGetExchangeRate('EUR')
 
// Server side
export async function getExchangeRate(baseCurrency: ExchangeRateQuery) {
  const { getToken } = auth()
 
  return await apiCall(getToken, path, 'get', {
    query: {
      base: baseCurrency,
    },
  })
}
 
// Usage on the server (Next.js RSC for example)
const exchangeRate = await getExchangeRate('EUR')

Also, maybe we want to extract the type of the response because we need to pass it down to another React component via a prop:

export interface SubComponentProps {
  // You probably want to abstract this type to `openApiTypes.ts` as you might need it in several places
  exchangeRate: ResponseType<'/currency/exchangerate', 'get'>
}
 
export const SubComponent = ({
  exchangeRate: { base, date, rates },
}: SubComponentProps) => {
  return ...
}

All of these helper types are available in openApiTypes.ts. Each of them allow us to extract some info about the query:

  • ResponseType will give the expected return type
  • RequestParams will give all the expected parameters to pass in the query and path
  • PathParams will only give the path params (/users/:userId will give you { userId: string })
  • RequestBody will give the type of the expected body in POST/PUT/PATCH methods

The beauty in this solution is that we only ever rely on generated types, that means when the backend changes, and we update our generated types, we'll immediately know what changed simply by checking our types.

We are getting pretty damn close to great DX in REST aren't we? 😎

⚠ī¸

One thing to remember. You should only rely on swaggers you trust. Unlike trpc and GraphQL, this solution only gives you types, it doesn't ensure runtime validation of the data you receive. If the endpoints you're are targeting are notoriously unstable, I'd advise you use zod to also validate the data at runtime.

picture of me
Written by Nathan Brachotte

I'm a Product Engineer at heart, I've helped many companies build great team culture and craft high-performance, customer-centric, well-architected apps.
✨ Always aiming for that UI & UX extra touch ✨