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 GraphQL, we use the schema as a source (GraphQL checks at runtime that the types sent and received match the schema),
- And with tRPC, 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:
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.
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:
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 typeRequestParams
will give all the expected parameters to pass in the query and pathPathParams
will only give the path params (/users/:userId
will give you{ userId: string }
)RequestBody
will give the type of the expected body inPOST/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.