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.
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.
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.
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.
Generating the types and fetch functions with Orval is easy. We just add a simple config file to our project that tells Orval
// orval.config.jsmodule.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…
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 insidepostinstall
. 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 fileapi/generated-api.ts
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
).
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:
IssueControllerFindAllV3200
(note the V3
instead of V2
).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.tsexport { 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.tsimport { 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.tsexport { 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.tsimport { 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.
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) => (<IssueRowkey={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.
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.tsimport Axios, { AxiosRequestConfig } from "axios";// our global Axios instance including the base URLconst axios = Axios.create({baseURL: process.env.NEXT_PUBLIC_API_BASE_URL,});// this function was taken from the Orval docsexport 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.jsmodule.exports = {api: {input: "https://prolog-api.profy.dev/api-json",output: {target: "./api/generated-api.ts",override: {mutator: "./api/axios.ts",},},},};