React & REST APIs: End-To-End TypeScript Based On OpenAPI Docs

Johannes KettmannPublished on 

When you work on a React & TypeScript project that fetches data from a REST API, keeping your data types in sync can be problematic.

You obviously can create all the types on your frontend by hand. But this is a tedious and error-prone process. You might get a type wrong. Or the API changes and breaks the frontend code without you realizing it.

Ideally, the types on the frontend would always stay in sync with the backend. Automatically. No wrong types anymore. No anxiety attacks after an API update. TypeScript would warn you before pushing a bug to production.

There are different ways of achieving this kind of end-to-end type safety. You might have a TypeScript monorepo with shared types. Congrats. But this option might be off the table because e.g. your backend is written in a different programming language.

Still, you can easily benefit from end-to-end type safety if your REST API uses the OpenAPI standard (which is very common in production servers). Code generators can handle most of the tedious work for you. And on this page, you can learn how that works.

Our REST API

For this article, I created a REST API and an OpenAPI (formerly Swagger) documentation for it. I don’t want to go into the details here but it’s built with Nest.js and its Swagger plugin.

The API is hosted at https://prolog-api.profy.dev and you can see an example endpoint in action by visiting https://prolog-api.profy.dev/issue. It is part of an error-tracking application (similar to Sentry) that I created for the React Job Simulator.

Example: OpenAPI Documentation For Our REST API

If you’ve never seen OpenAPI or Swagger docs let me quickly walk you through this example. You can see the docs at https://prolog-api.profy.dev/api. When you open this page you can see all available endpoints (here even the different versions of those endpoints).

When you click on any one of those endpoints you can see an overview of all available query parameters, their types, and available options (for enums).

Also, note the “Try it out” button at the top right. This allows you to send requests to the API directly from the docs and inspect the responses.

Below the list of query parameters, you can see an example response of this endpoint.

For more details about the possible values, you can also inspect the schema.

You can see the types that are returned (e.g. items is an Issue array). In some cases, an enum is used (e.g. Issue.level) and you can see all possible values. This is very helpful if you need to use these enum values on the frontend. You don’t have to guess all possible values but can see them right in the docs.

How To Convert OpenAPI To TypeScript

It’s time to get our hands dirty. There are many options to generate TypeScript from an OpenAPI document. Each of these options uses the JSON representation of the docs (you can find ours at https://prolog-api.profy.dev/api-json).

On this page, we’ll use a code generator called Orval.

Orval seems like a good option because it’s actively maintained, has a good number of GitHub stars, and supports a wide range of features. It doesn’t only generate types but also supports the generation of fetch functions as well as react-query hooks.

Generate TypeScript With Orval

Generating the types and fetch functions with Orval is easy. We just add a simple config file to our project that tells Orval

  1. where to get the OpenAPI JSON from and
  2. where to write the TypeScript output.
// orval.config.js
module.exports = {
api: {
input: "https://prolog-api.profy.dev/api-json",
output: "./api/generated-api.ts",
},
};

In fact, you don’t even need this config file but could use the CLI options instead. Still using a file is more flexible and maintainable.

Now we simply execute Orval via npx.

npx orval

This creates a new file at our output target as we defined in the config file. We’ll have a look at that file in a bit. But first…

Automatically Generate Types Before Deployment

Ok, so generating the types is pretty simple. This saves us the tedious task of creating them by hand.

But as mentioned at the beginning of this page, it would be great if we could keep the types in sync with the REST API automatically. This way we’d realize if the types on our frontend are outdated before deploying code to production.

Unfortunately, we won’t be able to literally keep the types in sync. That would require us to listen to API updates. But we can update our types e.g. before we build and deploy our frontend. This way we at least catch any bugs before deployment.

A simple way to achieve this is by using the npm pre* or post* scripts in package.json.

// package.json
{
"scripts": {
"predev": "npm run generate-api",
"dev": "next dev",
"prebuild": "npm run generate-api",
"build": "next build",
"start": "next start",
"generate-api": "orval"
},
...
}

With these scripts, we run Orval (aka generate out types) before we run the dev or build command. Now we’re certain that we always work and deploy with the newest types.

Alternatively, we could also run the generate-api script inside postinstall. This works well when the project is deployed in a CI pipeline like GitHub Actions. Here we typically install the npm dependencies from scratch.

Since the types are now automatically created we don’t have to check the generated code in our Git repository anymore. Thus we can add a new line to our .gitignore file.

// .gitignore
# generated api file
api/generated-api.ts
You don't feel "job-ready" yet?
Working on a full-scale production React app is so different from personal projects. Especially without professional experience.
Believe me! I've been there. That's why I created a program that exposes you to
  • a production-grade code base
  • realistic tasks & workflows
  • high-end tooling setup
  • professional designs.

Overview Of Generated Code

As promised, let’s have a look at the generated code now. We start with a short example of some simple types:

// ./api/generated-api.ts
/**
* Generated by orval v6.10.2 🍺
* Do not edit manually.
* ProLog API
* The ProLog API documentation
* OpenAPI spec version: 1.0
*/
import axios from 'axios'
import type {
AxiosRequestConfig,
AxiosResponse
} from 'axios'
export const ProjectStatus = {
error: 'error',
warning: 'warning',
info: 'info',
} as const;
export const ProjectLanguage = {
react: 'react',
node: 'node',
python: 'python',
} as const;
export interface Project {
id: string;
name: string;
language: ProjectLanguage;
numIssues: number;
numEvents24h: number;
status: ProjectStatus;
}
export const projectControllerFindAll = <TData = AxiosResponse<Project[]>>(
options?: AxiosRequestConfig
): Promise<TData> => {
return axios.get(
`/v2/project`,options
);
}
export const projectControllerFindOne = <TData = AxiosResponse<Project>>(
id: string, options?: AxiosRequestConfig
): Promise<TData> => {
return axios.get(
`/v2/project/${id}`,options
);
}

At the top, we can find the type definitions that are used in the responses. ProjectStatus and ProjectLanguage are enums that are used by the Project interface/type.

At the bottom, we can also see two functions. We can use these to fetch data from the /v2/project and /v2/project/{id} endpoints. The functions are already typed with the correct parameter and response types. No need to bother with that anymore.

By default, the code generated by Orval uses Axios to send API requests. But we can also use a custom client instead (as we’ll see later).

The names of the types and functions above are fairly clear and simple. But we can also find more verbose types and functions like these (no worries if you don’t understand it right away):

export type IssueControllerFindAllV2200AllOf = {
items?: Issue[];
};
export type IssueControllerFindAllV2200 = PageDto & IssueControllerFindAllV2200AllOf;
export const IssueControllerFindAllV2Level = {
error: 'error',
warning: 'warning',
info: 'info',
} as const;
export const IssueControllerFindAllV2Status = {
open: 'open',
resolved: 'resolved',
} as const;
export type IssueControllerFindAllV2Params = { page?: number; limit?: number; status?: IssueControllerFindAllV2Status; level?: IssueControllerFindAllV2Level; project?: string };
export type IssueControllerFindAll200AllOf = {
items?: Issue[];
};
export type IssueControllerFindAll200 = PageDto & IssueControllerFindAll200AllOf;
export const IssueControllerFindAllLevel = {
error: 'error',
warning: 'warning',
info: 'info',
} as const;
export const IssueControllerFindAllStatus = {
open: 'open',
resolved: 'resolved',
} as const;
export type IssueControllerFindAllParams = { page?: number; limit?: number; status?: IssueControllerFindAllStatus; level?: IssueControllerFindAllLevel; project?: string };
export const issueControllerFindAll = <TData = AxiosResponse<IssueControllerFindAll200>>(
params?: IssueControllerFindAllParams, options?: AxiosRequestConfig
): Promise<TData> => {
return axios.get(
`/issue`,{
...options,
params: {...params, ...options?.params},}
);
}
export const issueControllerFindAllV2 = <TData = AxiosResponse<IssueControllerFindAllV2200>>(
params?: IssueControllerFindAllV2Params, options?: AxiosRequestConfig
): Promise<TData> => {
return axios.get(
`/v2/issue`,{
...options,
params: {...params, ...options?.params},}
);
}

These types and functions are used for version 1 and 2 of the issue endpoint (/issue and /v2/issue). Some of the names are pretty hard to read like type IssueControllerFindAllV2200 which includes the version (V2) and the response status code (200).

Create Type And Function Wrappers

As you already noticed, I’m not a big fan of names like IssueControllerFindAllV2200. But more importantly, these names might change for various reasons. For example:

  • Whenever there’s a new version, of the API we might suddenly have to switch to IssueControllerFindAllV3200 (note the V3 instead of V2).
  • We might want to swap out the codegen library and suddenly have to use totally different types and functions.

So if we couple our application too tightly to the generated TypeScript code it might be a lot of effort to adapt to these changes. We can decrease this risk by wrapping the generated types and functions and using these wrappers in our code.

Let’s start with a simple example: the types and functions for our /v2/project endpoint. All the types that we use in our application code can simply be re-exported.

// api/projects.types.ts
export { ProjectLanguage, ProjectStatus } from "./generated-api";
export type { Project } from "./generated-api";

We can also wrap the fetch function. This allows us to select a better name and extract the data object from the Axios response.

// api/projects.ts
import { projectControllerFindAll } from "./generated-api";
export async function getProjects() {
const { data } = await projectControllerFindAll();
return data;
}

This might not seem like a great deal yet. But remember? We also saw that the types and functions for the v2/issue endpoint were a bit more verbose.

Wrapping the types helps us to expose improved names to the rest of our codebase.

// api/issues.types.ts
export { IssueLevel } from "./generated-api";
export type {
Issue,
IssueControllerFindAllV2200 as IssuePage,
IssueControllerFindAllV2Params as IssueFilters,
} from "./generated-api";

Wrapping the fetch function, on the other hand, allows us to redefine the function parameters slightly and again extract the data object from the Axios response.

// api/issues.ts
import { issueControllerFindAllV2 } from "./generated-api";
import { IssueFilters } from "./issues.types";
export async function getIssues(
page: number,
filters: Omit<IssueFilters, "page">,
options?: { signal?: AbortSignal }
) {
const { data } = await issueControllerFindAllV2(
{ page, ...filters },
{ signal: options?.signal }
);
return data;
}

Thanks to this wrapper we could now simply swap out the issueControllerFindAllV2 function for any new version. Without touching any other code.

Using The Wrappers With react-query

As mentioned, apart from creating types and fetch functions Orval can also generate react-query hooks. While this can be helpful it also feels a bit limiting. For example, we wouldn’t be able to add additional logic to the hooks.

That’s why I’d rather create the react-query hooks manually and only use the generated fetch functions and types (actually our wrappers). Here is an example:

import { useQuery } from "@tanstack/react-query";
import { getIssues } from "@api/issues";
import type { IssuePage } from "@api/issues.types";
export function IssueList({ page }) {
const issuePage = useQuery(
["issues", page],
({ signal }) => getIssues(page, { status: "open" }, { signal }),
{ keepPreviousData: true }
);
return (
<table>
<tbody>
{(issuePage.data?.items || []).map((issue) => (
<IssueRow
key={issue.id}
issue={issue}
/>
))}
</tbody>
</table>
);
}

Don’t get me wrong, I don’t recommend adding the hook directly to the component. Rather separate this into an API layer as described in the previous article.

As you can see the data in our component is now correctly typed.

In fact, in the React Job Simulator project used in this article, I initially created all types by hand. And one of the first tasks in the job simulator is to fix a bug caused by a wrong type.

With our generated code we immediately understand that there’s a problem.

You don't feel "job-ready" yet?
Working on a full-scale production React app is so different from personal projects. Especially without professional experience.
Believe me! I've been there. That's why I created a program that exposes you to
  • a production-grade code base
  • realistic tasks & workflows
  • high-end tooling setup
  • professional designs.

Using A Custom Axios Instance

Our code is now properly typed and in sync with the backend. But unfortunately, it doesn’t work yet.

Orval by default doesn’t use a base URL in the generated fetch functions. Here a simplified example from the generated code:

export const projectControllerFindAll = (options) => {
return axios.get(
`/v2/project`,options
);
}

Our server doesn’t run on the same domain as our frontend. So currently our API requests return a 404. We have to set a base URL.

As mentioned before, Orval allows us to create a custom HTTP client. So we can create a global Axios instance, set the base URL there, and use this instance as a custom HTTP client.

// ./api/axios.ts
import Axios, { AxiosRequestConfig } from "axios";
// our global Axios instance including the base URL
const axios = Axios.create({
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL,
});
// this function was taken from the Orval docs
export default async function customInstance<T>(
config: AxiosRequestConfig,
options?: AxiosRequestConfig
): Promise<T> {
const { data } = await axios({ ...config, ...options });
return data;
};

For more information about the customInstance function check out the Orval docs.

Now we only need to tell Orval to use this custom instance. This is straightforward by adding the mutator option to the config.

// ./orval.config.js
module.exports = {
api: {
input: "https://prolog-api.profy.dev/api-json",
output: {
target: "./api/generated-api.ts",
override: {
mutator: "./api/axios.ts",
},
},
},
};