Building a Super-Robust HTTP API with Isomorphic TypeScript
Sep 10, 2019
I've been a fan of TypeScript for a few years now, but it wasn't until I used it isomorphically that I really appreciated how beneficial it can be when it comes to making systems more robust.
In this post, I'm going to be making a really simple API, with a single endpoint. The API is for a car dealership, and the endpoint is /cars/:id
which will return information about a specific car.
The first step is to make our API endpoint. This will be a simple TypeScript function, living in /src/api/get-car-by-id.ts
(for the sake of brevity, imagine that there is some framework, such as Azure Functions, to map the API URL to the specific file) and will look something like this:
const endpoint = async (params: { id: string }) => {
const car = await database.getCar(id);
return {
id: car.id,
make: car.make,
model: car.model,
price: car.price
};
};
export default endpoint;
There's a business reason for manually selecting properties from the car model - we don't want customers to know how long the car has been for sale for, as it gives them unnecessary leverage in negotiations.
The second step towards making this API really robust is to, rather than just manually calling http://my-api.example.com/cars/a73bef
, we should actually create another function that encapsulates it. That way, the consumers of our API don't have to worry about making untyped HTTP requests, they can simply call our typed function. Let's make /src/client/get-car-by-id.ts
export const getCarById = (id: string): Promise<any> => request<any>(`http://my-api.example.com/cars/${id}`);
This function could then be distributed, on npm for example, for consumers to install and use.
There would be a few benefits of distributing the client above as it is:
- API URL structure is no longer the concern of consumers
- The required parameters are clearly specified by the function signature, and typed!
- Any authentication required could also be built into the function
However, for maximum robustness, there are two more steps to take. Firstly, let's go back to the first file, src/api/get-car-by-id.ts
, and make an interface that corresponds to what we're returning. While we're there, let's enforce the return type of endpoint
to be that interface
interface GetCarByIdResponse {
id: string;
make: string;
model: string;
price: number;
}
const endpoint = async (params: { id: string }): GetCarByIdResponse => {
/* function contents haven't changed */
};
export { GetCarByIdResponse };
export default endpoint;
Now, we'll get a compiler error if we were to change endpoint
to return something that wasn't a GetCarByIdResponse
- we have created a code contract that enforces the API response.
You'll also notice that the interface itself is being exported in the file. This means that we can now make it so that our client has the same code contract:
import { GetCarByIdResponse } from "../api/get-car-by-id";
export const getCarById = (id: string): Promise<GetCarByIdResponse> => request<GetCarByIdResponse>(`http://my-api.example.com/cars/${id}`);
Any consumer of that client will now benefit from the typings. They can write code like this, which will error if they try to use properties that aren't there:
import { getCarById } from "published-car-dealership-api-client";
const car = await getCarById(someId);
car.make // intellisense will know it's a string
car.price // intellisense will know it's a number
car.engineSize // compiler error!!
Because the API itself and the distributed client live together, changes to one must be reflected in the other, or the project won't build. This, coupled with potentially setting up a deployment pipeline to automatically publish the client, will result in an API that's very difficult to break!