Better typescript fetch clients

Time to read: 11 minutes

Fetch is used in the browser to interact with API's over HTTP(S). Fetch works well for this. It is promise based. These are facts.

When you work with an API you often want type safety.

There is a popular piece by Kent C. Dodds on this. Kent wraps the request and handles coercing with some additional code. Check it out here.

I have my own preferred way to work with this using type intersections and pure functions.

My goal is to create a generic way to handle fetch; but I do not want to reshape the Response object, I want to pass that out.

This helps allow anyone consuming my fetch API client to extend for their own use cases.

If I make a design decision now to return something like { data: any, error: Error } I can paint myself into a corner.

Instead these clients will just return a full Response and when someone tries to use .json() the correct type will be set as if by magic (AKA inference, and narrowing*).

TL;DR

The code is here if you just want to get on with your day...

// ../_snippets/fetch-clients/example.ts

/** For 201 */
type UserCreated = { id: string; name: string };

/** For 400 */
type BadRequest = { code: "bad_request"; message: string };

/** Response type intersection */
type UserResponse =
  | (Omit<Response, "json"> & {
      status: 201;
      json: () => UserCreated | PromiseLike<UserCreated>;
    })
  | (Omit<Response, "json"> & {
      status: 400;
      json: () => BadRequest | PromiseLike<BadRequest>;
    });

/** Marshalling stream to object with narrowing */
const marshalResponse = (res: UserResponse) => {
  if (res.status === 201) return res.json();
  if (res.status === 400) return res.json();
  return Error("Unhandled response code");
};

/** Coerce Response to UserResponse */
const responseHandler = (response: Response) => {
  const res = response as UserResponse;
  return marshalResponse(res);
};

/** Usage returns typed data */
const data = fetch(`https://api.com/v1/user`, {
  method: "POST",
  body: JSON.stringify({ name: "Simon" }),
}).then((res) => responseHandler(res));

The type of data in the above is:

UserCreated | BadRequest | Error;

I will deep dive this below and show how this all works.

An example

If you had an API that could create users you would send a POST request in fetch like below:

// ../_snippets/fetch-clients/request.ts

fetch(`https://user.api.com`, {
  method: "POST",
  body: JSON.stringify({
    name: "Simon",
  }),
});

This would create a promise, and that promise would have the type Promise<Response>.

To access the data that is returned from the API you would want to call .json() on the response.

The type of Response['json'] is a Promise<any> and that is not very helpful (this is a fact).

As part of the domain transfer layer you probably want to get your type back.

Lets suppose the API can return two bodies, one for a 201 code and one for a 400 code. Those will correspond to the Created and BadRequest responses.

// ../_snippets/fetch-clients/domain-transfer-objects.ts

export type UserCreated = {
  id: string;
  name: string;
};

export type BadRequest = {
  code: "bad_request";
  message: string;
};

Our API will always return a 400 error of this kind, and a 201 on success.

Response Typing

We can use an intersection type on the Response type to narrow and restrict response to the two known types I expect.

Our user response look like this:

// ../_snippets/fetch-clients/response-user.ts

/**
 * The UserResponse is a Union of intersection that
 * will narrow when the status is asserted
 */
export type UserResponse =
  | (Omit<Response, "json"> & {
      status: 201;
      json: () => UserCreated | PromiseLike<UserCreated>;
    })
  | (Omit<Response, "json"> & {
      status: 400;
      json: () => BadRequest | PromiseLike<BadRequest>;
    });

I do a few things in this type:

  1. Omit (remove) the standard json that returns any. This is needed or I cannot narrow effectively because all types can be any and because that satisfies all constraints the compiler will helpfully always default to this.
  2. Make a union of intersections (&) that will narrow by relating the specific HTTP status code to a specific response.

I have used PromiseLike instead of promise, because that is the type that the underlying Response wants to have when it chains promises via then or catch.

Narrowing Response

Now I want to use the UserResponse type to handle the response from fetch. We want this type to narrow the possible values of .json() to BadRequest | UserCreated.

To do that I will perform assertions on the status:

// ../_snippets/fetch-clients/marshal.ts

import { UserResponse } from "./response-user";

/**
 * This is the Dto layer. If you want to coerce types like
 * string representations of dates into real date objects
 * then do it here.
 */
export const marshalResponse = (res: UserResponse) => {
  if (res.status === 201) return res.json();
  if (res.status === 400) return res.json();
  return Error("Unhandled response code");
};

I have returned an error here, but you don't need to, you could expand the interface to return an empty object {} if the response code is unhandled :+1:.

Whatever you do, do not throw. Friends don't let friends use errors for flow control!

Next, I need to wrap the marshal function into a response handler.

// ../_snippets/fetch-clients/handlers.ts

import { marshalResponse } from "./marshal";
import { UserResponse } from "./response-user";

export const responseHandler = (response: Response) => {
  const res = response as UserResponse;

  return marshalResponse(res);
};

The responsibility of this function is to type cast the Response to a UserResponse and pass it into the marshal function.

Putting it all together

Let's first look at the await case.

// ../_snippets/fetch-clients/request-await.ts

import { responseHandler } from "./handlers";

const response = await fetch(`https://api.com/v1/user`, {
  method: "POST",
  body: JSON.stringify({
    name: "Simon",
  }),
});

export const body = await responseHandler(response);

So what type do you think body is now?

Fetch client response typed with await

And when we just use promises:

// ../_snippets/fetch-clients/request-promise.ts

import { responseHandler } from "./handlers";

export const data = fetch(`https://api.com/v1/user`, {
  method: "POST",
  body: JSON.stringify({
    name: "Simon",
  }),
}).then((res) => responseHandler(res));

Fetch client response typed with await

Wrap up

This concludes the explanation of how to make fetch return the types that you want for your JSON bodies, without masking the underling Response type.

The possibilities for this kind of type are to create general API clients, that have inferred and narrowed response types, without writing lots of wrapper code, and without masking the underlying response object (open for extension).

I hope you find it useful.