Stop breaking your REST API & HTTP requests with Typescript
When adopting the same typed language from the backend to the frontend helps you refactor integrations with confidence.
Table of content
- Let's meet Typescript, or re-discover its benefits
- Ensuring our API & clients both stay in sync by defining a contract between them
- Sharing the contract between them using packages
- Enforcing the contract on the API side
- Enforcing the contract on the client side
- Staying in sync, on updates & new versions
- Wrapping things up
It's launch day. While releasing a new version of your application to users is always a stressful moment, your tests indicate that everything is good to go. You feel pretty confident about pushing this last button, only to realize that one of your page is broken.
After investigating, it looks like the API is returning a new status for orders which is not taken into account by your user interface.
While this bug might sound too simplistic, it is only an example of the risks of choosing to integrate two systems using REST, which lacks type safety. However, the overwhelming majority of organizations using this transport method still prefer it for its versatility & speed of development.
Thankfully, new technologies like GraphQL have emerged that solve this issue (and many others we won't mention here), but they require lots of adjustments for existing systems using REST.
How can we improve the type safety of our APIs while sticking to REST? Could a bug like this have been detected before making this release?
Let's meet Typescript, or re-discover its benefits
If your one of the many companies or engineer using the Javascript ecosystem, chances are you already heard about Typescript. With Typescript, you catch (most) errors at compile time, instead of runtime, making it easier to refactor huge chunks of code without feeling that something will eventually break.
When you start using Typescript in all parts of your codebase (from the UI to the back-end), its benefits are even more interesting. Let's explore how we can use it to type safe our HTTP integration.
Ensuring our API & clients both stay in sync by defining a contract between them
As described in our example, there are many reasons why the integration between your API & client might break over time:
- Are you mocking API calls in your client's integration tests in a way that make it impossible to detect breaking changes?
- Did you decide to not version your API endpoints because, well, you have control over all clients consuming them?
- Did you decide to not use snapshots to track response updates in your endpoints?
By using Typescript, you have a powerful way to ensure several entities follow the same rules: interfaces.
interface Order {
id: string
amount: number
currency: Currency
links: {
user: string
}
}
interface GetOrdersResponse {
data: Order[]
}
By creating these two interfaces, Order
& GetOrdersResponse
, we define a contract which describes the integration between a producer (our API) & a consumer (our client).
When requesting a list of orders, we expect the API to return a response which contains a data property made of orders.
Now, while it sounds interesting, there are few questions remaining:
- How do we share this contract between different services which could be defined in different repositories?
- How do we enforce this contract from both perspectives?
- How do we manage updates to this contract?
Sharing the contract between them using packages
When it comes to sharing code, there's a favoured solution in the Javascript ecosystem: packages. Private or public, published on NPM or available locally through a yarn workspace, they allow us to reuse logic across different applications.
Regarding our example, they have several advantages:
- We can keep track of their version using semantic versioning, the same way we do it for the API itself. If a new major of the API is available, we publish a new major version of the package
- They can contain more things that the contract itself, like mocks. This way, every client can easily mock calls to the API in their tests while making sure they reflect the reality.
interface Order {
id: string
amount: number
currency: Currency
links: {
user: string
}
}
export const anOrder = (order?: Partial<Order>): Order => ({
id: 'f0c21386-40e2-4e10-8b0c-97cf81a48fa6'
amount: 120.5
currency: 'USD'
links: {
user: '51720247-b7b9-4e85-8a1c-12bf2980506c'
}
...order
})
Enforcing the contract on the API side
If you're used to work with GraphQL, you already know that it's actively forcing you to resolve every field's value to the type defined in your GraphQL schema. When working with REST, there's nothing by default preventing you from messing things up. Let's see how we can change that.
import express, { RequestHandler } from 'express'
import { GetOrdersResponse } from '@mycompany/orders-shared'
const getOrders: RequestHandler<unknown, GetOrdersResponse> = (req, res) => {
return res.json({
// Typescript is gonna complain about this
poop: true,
})
}
const app = express()
app.get('/orders', getOrders)
Here we're using the Express Node.js web framework, which comes with Typescript support if you install the NPM package @types/express
. We can enforce our contract by typing our handler with the RequestHandler
express's interface. It comes with Generics support, which means you can pass extra information between <>
to specify how some objects will & should look like.
By passing the GetOrdersResponse
interface as second argument, we're telling Express that we expect our response to return a JSON object that looks like our interface.
Feel free to explore which other interfaces you can pass to the RequestHandler
type in order to enforce certain things like request body shape, request get parameters & more.
If you're used to different web frameworks, chances are these mechanisms are also available to you for use. For example, when you define api routes using Next.js, you can do as follow:
import type { NextApiRequest, NextApiResponse } from 'next'
import { GetOrdersResponse } from '@mycompany/orders-shared'
export default (
req: NextApiRequest,
res: NextApiResponse<GetOrdersResponse>,
) => {
res.status(200).json({ name: 'John Doe' })
}
Enforcing the contract on the client side
Like on the API side, several mechanisms are available to you on the client side to type an HTTP request as a specific object and thus, make sure all code consuming it only use existing & valid properties.
Here we're using the Javascript Fetch API, available on all modern browsers to integrate with our REST API & fetch orders.
interface SuccessfulResponse<Data extends Record<string, any>> {
ok: true
data: Data
}
interface ErrorResponse {
ok: false
data: undefined
}
type RequestResponse<Data extends Record<string, any>> =
| SuccessfulResponse<Data>
| ErrorResponse
const request = async <Data extends Record<string, any>>(
url: string,
options: RequestInit,
): Promise<RequestResponse<Data>> => {
try {
const response = await fetch(url, options)
if (!response.ok) {
throw new Error(response.statusText)
}
const data = await response.json()
return {
ok: true,
data,
}
} catch (e) {
return {
ok: false,
data: undefined,
}
}
}
// In your application code...
const response = await request<GetOrdersResponse>('/api/orders', {
method: 'get',
})
if (response.ok) {
response.data.data
}
We implement a request function which leverages generics in a similar way to Express or Next.js. Its unique role is to fetch the resource available at the specified url, convert the response body to a JSON object and type it with our interface.
Staying in sync, on updates & new versions
What is fantastic about this and Typescript in general is that if we don't respect our own constraints, our code will not compile, thus avoiding errors at runtime.
However, it does not prevent you from having issues if both entities (API & user interface) are not deployed at the same time and that you introduce a breaking change. In this case, your code will compile but errors will occur at runtime.
In this scenario, it's best to follow good API versioning practices like:
- Using major versions when introducing breaking changes, to allow previous clients to still interact with a previous major API version
- Deprecate properties in minor versions if necessary to let clients update their code before removing them completely in the next major version
Wrapping things up
Using the same language & ecosystem from the frontend to the backend has a lot of benefits, as seen with this use case.
However, the technical aspect of this type of migration/update is only the tip of the iceberg. To ensure effective collaboration, thus avoiding releases issues all together, teams & engineers can also use:
Want to ship localized content faster
without loosing developers time?
Recontent helps product teams collaborate on content for web & mobile apps.
No more hardcoded keys, outdated text or misleading translations.