Creating generic types for API (backend) responses
Andrzej Legutko
Posted on May 25, 2024
I wanted to share a little TypeScript tip that I tend to utilize in my projects whenever there's a REST API between the frontend and the backend. I hope you'll find it as useful I do!
Setting the scene
Suppose you're developing a TypeScript web application and your backend is using REST API. In fact maybe they've already made all the endpoints and responses for your future requests and all you've got are Open API docs (Swaggger) documenting your API.
You're drowning in data types for all the different endpoints and their methods: GET on api/v1/blogs/:id
, POST on api/v1/blogs/
, login on api/v1/token
, register, refresh token, add user, patch this, etc. etc.
If you are as big of a fan of TypeScript fan as I am you would probably like your TypeScript static code analysis to help you manage the backend/frontend contracts and keep track of what's what.
But the issue is that the same data can actually have different shapes depending on whether you're posting them to the backend or if you're getting them in a response. How to handle this with TS and not completely lose track while creating tens/hundreds of different types/interfaces?
The data types
The basic idea is: you start with creating all the types you actually utilze as a frontend application (registration/user profile credentials, login credentials, form submission data shapes for all the different data submission logic you have in your application etc.).
But you ignore the elements that are handled by the backend e.g. the id
and created_at
fields, paginated or queried results etc.
Suppose we have interfaces like this:
interface Blogpost {
title: string;
content: string;
date: string;
summary: string:
tags: string[];
// ... you get the idea
}
interface Comment {
parent_id: number;
author: string;
email?: string;
}
They're all handled using the create operation in our REST API's CRUD repertoire. That means our backend will handle them all (in this example: both) in the exact same way. Wen can thus create an auxiliary union type (it makes sense the more types your application needs, especially if they have similar but different shapes):
type ApiTypes = Comment | Blogpost;
The ApiResponse types
Now that we defined what we - the frontend - use, it's time to create a type that handles the "stuff" that backend throws at us. We create a generic type e.g.:
type ApiResponse<T extends ApiTypes> = T & {
id: number,
created_at?: string,
}
The extends
in the generic argument limits the types/interfaces we can supply to the ApiResponse (which is what we want as not all of our backend responses will have the same shape, e.g. a login form response might just contain the authorization tokens).
Another schema might be suitable for e.g. paginated result:
interface PaginatedResults<T extends ApiTypes> = {
count: number;
previous: string | null;
next: string | null;
results: T[];
}
Now whenever we need to handle a response from our API we can just swap in the appropriate type into our generic types, e.g. when using TanStack Query and Axios when posting data (the mutation in TanStack Query):
The mutation function:
function sendFormData<T extends ApiTypes>(url: string) {
return async (data: T): Promise<ApiResponse<T>> => {
const response: AxiosResponse<ApiResponse<T>> = await
axios.post<ApiResponse<T>>(url, JSON.stringify(data));
return response.data;
};
}
The actual useMutation
hook:
const mutation: UseMutationResult<
ApiResponse<T>,
ErrorResponse
> = useMutation<ApiResponse<T>, ErrorResponse, T>({
mutationFn: sendFormData<T>(URL),
});
As a bonus you can see how an explicitly typed out TanStack Query's useMutation
looks like. It might seem excessive, but like with all of TypeScript, from now on it does the work for you during development.
And when you need to add a new data type to this scheme, you just start from the top: create the type, add it to the ApiTypes
union and voilà - you can now utilze the server state functions presented above by just plopping in the new type in place of the generic T
type.
A comment on types vs. interfaces
As you probably know types (aka type aliases) and interfaces have almost the exact same use cases and utility. So why was I using them so inconsitently here, specifically breaking the convention of using interfaces to introduce ApiResponse
as a type alias?
The natural next step on the road to make the world a cleaner and more consistent place would be to introduce ApiResponse
as something like this:
interface ApiResponse<T extends ApiTypes> extends T {
id: number;
created_at?: string;
}
Unfortunately this doesn't work - the TS static code analysis tool explains why:
TS2312: An interface can only extend an object type or intersection of object types with statically known members.
That's it. That's the reason. If you're more into TypeScript be sure to led me and the readers know why TS is designed this way, but as far as the I see it, it's just a subtle difference between a type and an interface that we have to accept.
source for the picture: photo by Savvas Stavrinos
Posted on May 25, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.