Creating generic types for API (backend) responses

lurco

Andrzej Legutko

Posted on May 25, 2024

Creating generic types for API (backend) responses

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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,
}
Enter fullscreen mode Exit fullscreen mode

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[];
}
Enter fullscreen mode Exit fullscreen mode

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;
    };
}
Enter fullscreen mode Exit fullscreen mode

The actual useMutation hook:

 const mutation: UseMutationResult<
    ApiResponse<T>,
    ErrorResponse
  > = useMutation<ApiResponse<T>, ErrorResponse, T>({
    mutationFn: sendFormData<T>(URL),
  });
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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

💖 💪 🙅 🚩
lurco
Andrzej Legutko

Posted on May 25, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related