Creating a Type-Safe Fetch API Client

When developing a frontend application, interacting with APIs and fetching data from a server is a common necessity. A significant portion of the business logic often resides on the server. To facilitate communication with the server, we create an API client, centralizing and streamlining all requests to the server through a consistent approach.

In this article, we’ll discuss effective ways to implement a type-safe API client, leveraging TypeScript and OpenAPI specifications. Having a type-safe API client ensures that your application adheres to the expected data structures and reduces the risk of runtime errors, leading to a more robust and maintainable codebase.

Scenarios and Considerations

Before diving into the implementation details, let’s consider a few scenarios:

Frameworks with Built-in Remote Procedure Call (RPC) / Server Actions: If your backend is already using frameworks like Next.js, SolidStart, or others that support RPC (for example, using server actions with "use server"), you may not encounter significant challenges when calling your server from the client.

tRPC Integration: If your backend utilizes tRPC, a TypeScript-first API framework, you’re well-equipped with a robust and type-safe solution out of the box.

Backend Written in Other Languages: If your backend is written in a language other than TypeScript, or if your server framework doesn’t expose an API client, you’ll need to create a custom client to communicate with the server.

The Manual Approach

In the early stages of my career, I worked with backends written in Python and C#. These projects were often in separate repositories from my codebase. To call the server, I had to manually write a client that would handle fetch requests.

This client contained all the fetch requests to the server. However, writing it manually led to several problems:

  • Repetitive work: I had to rewrite the client with every new API endpoint.
  • Potential errors: Mistakes could occur with URLs, parameters, and the data expected by the backend.
  • Manual type management: All types had to be written manually, increasing the risk of inconsistencies.

Here’s an example of how tedious and error-prone the manual approach can be:

interface Pet {
  id?: number;
  name: string;
}

const getPetById = async (petId: number): Promise<Pet> => {
  const response = await fetch(`/pet/${petId}`);

  if (!response.ok) {
    throw new Error("Failed to fetch pet");
  }

  return await response.json();
};

Even when working in a monorepo that used a TypeScript backend without an exposed API client, manual effort was required, although consuming shared types was more straightforward.

Generating OpenAPI Specifications from the Backend

Most backend developers are familiar with OpenAPI, a standard for describing REST APIs. The first step in creating a type-safe API client is to generate the OpenAPI specification from the backend.

Many backend developers also use Swagger to document their APIs, which conveniently exposes an OpenAPI specification.

Regardless of the backend technology, backend developers can typically find a way to generate the OpenAPI specification for their APIs.

Here’s an example of what an OpenAPI specification might look like:

openapi: 3.0.0
info:
  title: Sample Pet Store App
  description: "This is a sample server for a pet store."
  version: 1.0.0
servers:
  - url: "http://petstore.swagger.io/v1"
paths:
  "/pet/{petId}":
    get:
      tags:
        - pet
      summary: Find pet by ID
      description: Returns a single pet
      operationId: getPetById
      parameters:
        - name: petId
          in: path
          description: ID of pet to return
          required: true
          schema:
            type: integer
            format: int64
      responses:
        "200":
          description: successful operation
          content:
            application/xml:
              schema:
                $ref: "#/components/schemas/Pet"
            application/json:
              schema:
                $ref: "#/components/schemas/Pet"
        "400":
          description: Invalid ID supplied
        "404":
          description: Pet not found

components:
  schemas:
    Pet:
      required:
        - name
      properties:
        id:
          type: integer
          format: int64
        name:
          type: string

Generating a Fetch Client from the OpenAPI Specification

With the OpenAPI specification in hand, we can generate a fetch client from it. Several tools can assist with this process, and they often have similar names:

Tools Considered

openapi-ts - For those seeking flexibility and services, even with increased boilerplate.

This tool generates a substantial boilerplate that you can use to configure your API. The idea is to generate the core fetch API client once, and then use the CLI to generate types and functions as needed. You can replace the core implementation and gain more control.

The request function accepts parameter options and should handle the fetch request. You can override it as desired, as long as you maintain the contract. From an ergonomic perspective, everything is separated into Service classes with static methods, and the types are exposed nicely. It also provides options for canceling fetch requests out of the box.

openapi-ts-file-structure.png

The advantage is control and flexibility, but at the cost of more boilerplate.

Example:

// Usage
await PetService.getPetById({
  petId: 1,
});

// types.gen.ts
export type GetPetByIdData = {
  /**
   * ID of pet to return
   */
  petId: number;
};

export type GetPetByIdResponse = Pet;

export type Pet = {
  id?: number;
  name: string;
};

// services.gen.ts
export class PetService {
  /**
   * Find pet by ID
   * Returns a single pet
   * @param data The data for the request.
   * @param data.petId ID of pet to return
   * @returns Pet successful operation
   * @throws ApiError
   */
  public static getPetById(
    data: GetPetByIdData,
  ): CancelablePromise<GetPetByIdResponse> {
    return __request(OpenAPI, {
      method: "GET",
      url: "/pet/{petId}",
      path: {
        petId: data.petId,
      },
      errors: {
        400: "Invalid ID supplied",
        404: "Pet not found",
      },
    });
  }
}

Playground of this example

typed-openapi - A single file, with the ability to use types, runtime validation, and custom fetchers.

This tool generates a single file containing the API client and related types. You can create the API client yourself using methods, URLs, and parameters, as demonstrated by the provided fetch request example.

// Generate the client
const api = createApiClient(async (method, url, params) => {
  const response = await fetch(url, { method, body: JSON.stringify(params) });
  return await response.json();
});

const pet = await api.get("/pet/{petId}", { path: { petId: "1" } });

// They provide Schemas namespace
export namespace Schemas {
  export type Pet = {
    id?: number | undefined;
    name: string;
  };
}

// Endpoint types
export namespace Endpoints {
  export type get_GetPetById = {
    method: "GET";
    path: "/pet/{petId}";
    parameters: {
      path: { petId: number };
    };
    response: Schemas.Pet;
  };
}

Playground of this example.

In addition, this tool can generate similar code that uses runtime validation libraries like zod (and use z.Infer for types).

openapi-zod-client - For those using zodius and runtime validation.

This tool integrates with zodius, a TypeScript HTTP client and server with Zod validation. It’s suitable for scenarios where runtime validation is desired.

My Requirements and Chosen Solution

After considering the available options, I decided against runtime validation for fetch requests. Most of the time, when dealing with user input or unknown third-party services, runtime validation is essential for protection. However, in my case, the backend follows the OpenAPI schema it generates, so I deemed runtime validation unnecessary and opted to avoid the additional runtime and bundle size costs.

Additionally, I wanted to have a single generated file to avoid cluttering the codebase and minimize git diff changes when the API changes.

Based on these requirements, I chose to use openapi-fetch, which includes two parts:

  • Types generator from OpenAPI specifications with openapi-typescript
  • Runtime fetch client connected to the types with openapi-fetch (but you can replace it with other options)

For type generation, I created a script in package.json:

{
  "generate-api": "openapi-typescript \"../../apps/backend/docs/openapi.yaml\" --output ./src/lib/api/api.d.ts"
}

This script generates an api.d.ts file containing the API types. Then, I used the runtime openapi-fetch (5kb) to perform the fetch requests, using the native fetch under the hood:

import createClient from "openapi-fetch";
import { env } from "@/env";
import type { paths } from "./api/api";

export const api = createClient<paths>({
  baseUrl: env.NEXT_PUBLIC_API_BASE_URL,
});

I can then call my APIs like this:

const { data: pet } = await api.GET("/pets/{petId}", {
    params: {
        path: { petId },
    },
});

if (pet == null) {
    return notFound();
}

Notice that it returns a Go-like style of data and error objects without throwing exceptions.

export type FetchResponse<T, O, Media extends MediaType> =
  | {
      data: ParseAsResponse<
        GetValueWithDefault<
          SuccessResponse<ResponseObjectMap<T>>,
          Media,
          Record<string, never>
        >,
        O
      >;
      error?: never;
      response: Response;
    }
  | {
      data?: never;
      error: GetValueWithDefault<
        ErrorResponse<ResponseObjectMap<T>>,
        Media,
        Record<string, never>
      >;
      response: Response;
    };

If desired, I could create a middleware to throw errors instead:

api.use({
  onRequest: (req, options) => {
    // Do something
  },
  onResponse: (req, options) => {
    // Do something
  },
});

To get a type manually, I can use:

import type { components } from "@/lib/api/api";

type Pet = components["schemas"]["model.Pet"];

work-type-safe-result.png

Conclusion

In 2024, we no longer need to declare fetch clients by hand. Various powerful tools are available to generate type-safe API clients with customization options. I encourage you to explore these tools and choose one that fits your project’s requirements, saving valuable time and effort in the process.