12 Jun 2024 | 9 min read | 2.5k views

Type-safe TanStack Query with OpenAPI

TanStack Query (formerly React Query) is an asynchronous state management library for React. You tell it where to get your data - typically from an API, but it could be from any other source - and it takes care of caching, revalidation, background updates, etc.

OpenAPI (not the AI company), is an specification for documenting REST APIs. Used by big companies such as GitHub, Stripe, and Discord, it’s the most popular standard for describing APIs in a machine-readable format.

For a while, I’ve explored ways how to combine these two things together. I wanted to achieve a tRPC-like experience without having to refactor my backend.

In tRPC, you change your Prisma or Drizzle (or any type-safe ORM) schema and the types just flow to the frontend. End-to-end.

Here we are doing things slightly differently. We will leave the backend untouched. The OpenAPI specification is the source of truth.

I’m calling it… middle-to-end type safety.

The plan

We’re going to build a set of custom, type-safe, TanStack Query hooks for React that consume an API. The types are all going to be inferred from an OpenAPI specification.

In other words, we will wrap TanStack Query’s useQuery and useMutation hooks and sprinkling some TypeScript magic on top.

For turning OpenAPI schemas into TypeScript types, we are going to use the openapi-typescript package. We will also use the openapi-fetch, which nicely integrates with the former.

Step 1: Generating types from an OpenAPI spec

Let’s start by generating all TypeScript types from an existing OpenAPI specification.

Install the openapi-typescript package:

npm install -D openapi-typescript

For even better type-safety, turning on noUncheckedIndexedAccess in your tsconfig.json is recommended:

tsconfig.json
{
  "compilerOptions": {
    // ...
    "noUncheckedIndexedAccess": true
  }
}

Now, let’s generate the types:

npx openapi-typescript <OPENAPI_URL> --output src/api/types.ts

If you want to follow along with this post, I’m gonna be using GitHub’s OpenAPI specification. Here’s how you can generate types for it:

npx openapi-typescript https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.json --output src/api/types.ts
ℹ️ Keeping your API types in sync

In this post, we are fetching and generating the types from an OpenAPI specification once.

In the real-world, OpenAPI specifications change often. You might want to automate this process to avoid mismatches between the API and your client.

This doesn’t have to be complicated. A simple script with setInterval can do the job. Here’s one that can be used as a starting point:

import fs from 'node:fs'
import openapiTS from 'openapi-typescript'
 
const fetchSpec = async () => {
  const types = await openapiTS('http://example.com/openapi.json')
  fs.writeFileSync('./types.ts', types)
}
 
setInterval(fetchSpec, 10_000) // Runs every 10 seconds

With packages like concurrently, you can run this script together with your development server.

package.json
{
  // ...
  "scripts": {
    "start": "...",
    "typegen": "tsx fetch-spec.ts",
    "dev": "concurrently 'npm run start' 'npm run typegen'"
  }
}

Step 2: Setup the API client

Now that we have the types, let’s set up the API client. We are going to use openapi-fetch for this.

Install the openapi-fetch package:

npm install openapi-fetch

Create a new file called client.ts:

src/api/client.ts
import createClient from 'openapi-fetch'
import type { paths } from './types.ts'
 
const baseUrl = 'https://api.github.com'
 
const client = createClient<paths>({ baseUrl })
 
// ...

In this demo we are going to fetch Gists from GitHub, so we will need an API token to make requests.

In openapi-fetch, you can also define a middleware function that runs before each request:

src/api/client.ts
import createClient, { Middleware } from "openapi-fetch";
import type { paths } from './types.ts'
 
// ⚠️ Just for demo purposes, this will be included in the client bundle.
const githubToken = import.meta.env.VITE_GITHUB_TOKEN
const baseUrl = 'https://api.github.com'
 
const authMiddleware: Middleware = {
  async onRequest(req, _options) {
    req.headers.set('Authorization', `Bearer ${githubToken}`)
    return req
  },
}
 
const client = createClient<paths>({ baseUrl })
 
client.use(authMiddleware)

At this point, we have a type-safe API client that can make authorized requests to the GitHub API.

Type-safe OpenAPI client

Step 3: Create custom TanStack Query hooks

Now that we have a fully typed API client, let’s create a set of custom TanStack Query hooks using it.

We’re gonna thinly wrap TanStack Query’s useQuery and useMutation hooks. The goal is to replicate the same type experience as we have with openapi-fetch, this time with TanStack Query’s hooks.

The final API will be a combination of queries and mutations with HTTP methods. For example, to call a POST endpoint:

const createGist = usePostMutation('/gists')

So let’s begin.

POST hook

Create a new file called hooks.ts.

We will start with a naive implementation of the useMutation hook, with no type safety:

src/api/hooks.ts
import { client } from './client'
import { useMutation } from '@tanstack/react-query'
 
export function usePostMutation(path: string) {
  return useMutation({
    mutationFn: (params: Record<string, any>) => client.POST(path, params).then(({ data }) => data),
  })
}

Now let’s add types. We will need the help of an utility package called openapi-typescript-helpers:

npm install -D openapi-typescript-helpers

We want the path parameter to only be inferred from the paths that have a POST method in the OpenAPI specification. And then, from the path, we want to infer the parameters (body, params, querystring) and return type (response) of that exact endpoint.

Below, I’m defining two utility types for that: Paths and Params. I’ve also updated the usePostMutation hook to extend from them:

src/api/hooks.ts
import { client } from './client'
import { useMutation } from '@tanstack/react-query'
import { HttpMethod, PathsWithMethod } from 'openapi-typescript-helpers'
import { FetchOptions } from 'openapi-fetch'
 
type Paths<M extends HttpMethod> = PathsWithMethod<paths, M>
type Params<M extends HttpMethod, P extends Paths<M>> = M extends keyof paths[P]
  ? FetchOptions<paths[P][M]>
  : never
 
export function usePostMutation<P extends Paths<'post'>>(path: P) {
  return useMutation({
    mutationFn: (params: Params<'post', P>) => client.POST(path, params).then(({ data }) => data),
  })
}

We’re almost there. The missing piece is figuring out a way to pass extra options (TanStack Query options) to the useMutation hook.

TanStack Query’s UseMutationOptions type is a little tricky to use directly in this case. Here I am creating a new type that only picks the options we need:

src/api/hooks.ts
import { UseMutationOptions as RQUseMutationOptions } from '@tanstack/react-query'
 
type UseMutationOptions = Pick<RQUseMutationOptions, 'retry'> // Add more options as needed
 
// ...

Now we can update the usePostMutation function to accept these options:

src/api/hooks.ts
import { client } from './client'
import { useMutation } from '@tanstack/react-query'
import { HttpMethod, PathsWithMethod } from 'openapi-typescript-helpers'
import { FetchOptions } from 'openapi-fetch'
 
type Paths<M extends HttpMethod> = PathsWithMethod<paths, M>
type Params<M extends HttpMethod, P extends Paths<M>> = M extends keyof paths[P]
  ? FetchOptions<paths[P][M]>
  : never
 
type UseMutationOptions = Pick<RQUseMutationOptions, 'retry'>
 
export function usePostMutation<P extends Paths<'post'>>(path: P, options?: UseMutationOptions) {
  return useMutation({
    mutationFn: (params: Params<'post', P>) => client.POST(path, params).then(({ data }) => data),
    ...options,
  })
}

GET hook

Now let’s create a similar hook for GET requests.

GET requests are typically used for reading that and are OK to be cached, so we are going to wrap the useQuery hook this time:

src/api/hooks.ts
// ...
 
type UseQueryOptions = Pick<RQUseQueryOptions, 'enabled'> // Add more options as needed
 
export function useGetQuery<P extends Paths<'get'>>(
  path: P,
  params: Params<'get', P> & { rq?: UseQueryOptions }
) {
  return useQuery({
    queryKey: [path, params],
    queryFn: async () => client.GET(path, params).then(({ data }) => data),
    ...params?.rq,
  })
}

We are now creating a new type, UseQueryOptions. This follows the same logic as the UseMutationOptions type, but this time it’s for the useQuery hook.

In the params parameter, we are also adding a new property called rq (from React Query) for passing extra options to the useQuery hook.

The queryKey, which controls things like caching and revalidation, receives the path (unique across all GET endpoints) and the request parameters.

Other hooks (PUT, DELETE, etc.)

PUT, and DELETE endpoints typically mutate data, so they can be implemented in a similar way to the POST hook.

Here’s how you can implement the usePutMutation and useDeleteMutation hooks:

src/api/hooks.ts
export function usePutMutation<P extends Paths<'put'>>(path: P, options?: UseMutationOptions) {
  return useMutation({
    mutationFn: (params: Params<'put', P>) => 
      client.PUT(path, params).then(({ data }) => data),
    ...options,
  })
}
 
export function useDeleteMutation<P extends Paths<'delete'>>(
  path: P,
  options?: UseMutationOptions
) {
  return useMutation({
    mutationFn: (params: Params<'delete', P>) =>
      client.DELETE(path, params).then(({ data }) => data),
    ...options,
  })
}

At this point, we have a set of custom hooks that are fully typed according to GitHub’s OpenAPI specification. Let’s see it in action:

Type-safe TanStack Query hooks

Step 4: Usage in a React component

Finally, let’s see how we can use these hooks in a React component. Here’s a simple example that fetches, creates, and deletes Gists from GitHub. You can see the useGetQuery, usePostMutation, and useDeleteMutation hooks in action:

src/App.tsx
import { FormEvent } from 'react'
import './api/client'
import { useDeleteMutation, useGetQuery, usePostMutation } from './api/hooks'
 
export default function App() {
  // Mutations
  const createGist = usePostMutation('/gists')
  const removeGist = useDeleteMutation('/gists/{gist_id}')
 
  // Queries
  const gists = useGetQuery('/gists', { params: { query: { per_page: 5 } } })
 
  const handleCreate = (e: FormEvent) => {
    e.preventDefault()
 
    const onSuccess = () => gists.refetch()
    createGist.mutate(
      {
        body: {
          description: new Date().toISOString(),
          files: { 'greeting.txt': { content: 'hello, world' } },
        },
      },
      { onSuccess }
    )
  }
 
  const handleDelete = (id: string) => {
    if (!confirm('Are you sure?')) return
 
    const onSuccess = () => gists.refetch()
    removeGist.mutate({ params: { path: { gist_id: id } } }, { onSuccess })
  }
 
  return (
    <>
      <h1>Gists</h1>
      <ul>
        {gists.data?.map((gist) => (
          <li key={gist.id}>
            <strong>{gist.description || 'Untitled'}</strong>
            <small>{new Date(gist.created_at).toLocaleTimeString()}</small>
            <button onClick={() => handleDelete(gist.id)}></button>
          </li>
        ))}
      </ul>
 
      <form onSubmit={handleCreate}>
        <button type="submit">Create Gist</button>
      </form>
    </>
  )
}

Honorable mentions

Hopefully this post has shown you that integrating TanStack Query and OpenAPI together is not that hard.

If that wasn’t the case, there other open source tools that can help you with this task:

Each of these brings their own set of features and trade-offs, so make sure to check them out.

Conclusion

In this post, we learned how to turn an OpenAPI specification into type-safe TanStack Query hooks.

By using openapi-typescript and openapi-fetch, we were able to generate TypeScript types from an OpenAPI specification and create a fully typed API client.

We then created a set of custom hooks that wrap TanStack Query’s useQuery and useMutation hooks.

The end result is a set of hooks that are fully typed according to the OpenAPI specification.

All the code from this post is available in this repository. Feel free to check it out.

Questions or feedback? Feel free to reach out to me on X / Twitter. ✌️