DEV Community

Cover image for How to Use TypeScript to Accumulate Types: Typing ALL possible fetch() Results
José Pablo Ramírez Vargas
José Pablo Ramírez Vargas

Posted on • Originally published at webjose.hashnode.dev

How to Use TypeScript to Accumulate Types: Typing ALL possible fetch() Results

When I started re-writing (with my team) our application in TypeScript and Svelte (it was in JavaScript and React which we all hate), I was faced with a problem:

How can I safely type all possible bodies of an HTTP response?

Does this ring a bell to you? If not, you’re most likely “one of those”, hehe. Let’s digress for a moment to understand the picture better.

Why This Area Seems Unexplored

Nobody seems to care about “all possible bodies” of an HTTP response, as I could not find anything out there already made for this (well, maybe ts-fetch). Let me quickly go through my logic here about why this is.

Nobody cares because people either:

  1. Only care about the happy path: The response body when the HTTP status code is 2xx.

  2. People manually type it elsewhere.

For #1, I’d say that yes, developers (especially the inexperienced ones) forget that an HTTP request may fail, and that the information carried in the failed response is most likely completely different to the regular response.

For #2, let’s dig into a big issue found in popular NPM packages like ky and axios.

The Problem With Data-Fetching Packages

As far as I can tell, people like packages like ky or axios because one of their “features” is that they throw an error on non-OK HTTP status codes. Since when is this OK? Since never. But apparently people are not picking this up. People are happy and content getting errors on non-OK responses.

I imagine that people type non-OK bodies when it is time to catch. What a mess, what a code smell!

This is a code smell because you are effectively using try..catch blocks as branching statements, and try..catch is not meant to be a branching statement.

But even if you were to argue with me that branching happens naturally in try..catch, there is another big reason why this remains bad: When an error is thrown, the runtime needs to unwind the call stack. This is far more costly in terms of CPU cycles than regular branching with an if or switch statement.

Knowing this, can you justify the performance hit just to misuse the try..catch block? I say no. I cannot think of a single reason why the front-end world seems to be perfectly happy with this.

Now that I have explained my line of reasoning, let’s get back to the main topic.

The Problem, Detailed

An HTTP response may carry different information depending on its status code. For example, a todo endpoint such as api/todos/:id that receives a PATCH HTTP request may return a response with a different body when the response’s status code is 200 than when the response’s status code is 400.

Let’s exemplify:

// For the 200 response, a copy of the updated object:
{
    "id": 123,
    "text": "The updated text"
}

// For the 400 response, a list of validation errors:
{
    "errors": [
        "The updated text exceeds the maximum allowed number of characters."
    ]
}
Enter fullscreen mode Exit fullscreen mode

So, with this in mind we go back to the problem statement: How can I type a function that does this PATCH request where TypeScript can tell me which body I’m dealing with, depending on the HTTP status code as I write code? The answer: Use fluent syntax (builder syntax, chained syntax) to accumulate types.

Building the Solution

Let’s start by defining a type that builds upon a previous type:

export type AccumType<T, NewT> = T | NewT;
Enter fullscreen mode Exit fullscreen mode

Super simple: Given types T and NewT, join them to form a new type. Use this new type as T again in AccumType<>, and then you can accumulate another new type. This done by hand, however, is not nice. Let’s introduce another key piece for the solution: Fluent syntax.

Fluent Syntax

Given an object of class X whose methods always return itself (or a copy of itself), one can chain method calls one after the other. This is fluent syntax, or chained syntax.

Let’s write a simple class that does this:

export class NamePending<T> {
    accumulate<NewT>() {
        return this as NamePending<AccumType<T, NewT>>;
    }
}

// Now you can use it like this:
const x = new NamePending<{ a: number; }>();  // x is of type NamePending<{ a: number; }>.
const y = x.accumulate<{ b: string; }>  // y is of type NamePending<{ a: number; } | { b: string; }>.
Enter fullscreen mode Exit fullscreen mode

Eureka! We have successfully combined fluent syntax and the type we wrote to start accumulating data types into a single type!

In case it is not evident, you can continue the exercise until you’ve accumulated the desired types (x.accumulate().accumulate()… until you’re done).

This is all good and nice, but this super simple type is not tying up the HTTP status code to the corresponding body type.

Refining What We Have

What we want is to provide TypeScript with enough information so that its type-narrowing feature kicks in. To do this, let’s do the needful to obtain code that is relevant to the original problem (typing bodies of HTTP responses in a per-status code basis).

First, rename and evolve AccumType. The code below shows the progression in iterations:

// Iteration 1.
export type FetchResult<T, NewT> = T | NewT;
// Iteration 2.
export type FetchResponse<TStatus extends number, TBody> = {
    ok: boolean;
    status: TStatus;
    statusText: string;
    body: TBody
};

export type FetchResult<T, TStatus extends number, NewT> = 
    T | FetchResponse<TStatus, NewT>; //Makes sense to rename NewT to TBody.
Enter fullscreen mode Exit fullscreen mode

At this point, I realized something: Status codes are finite: I can (and did) look them up and define types for them, and use those types to restrict type parameter TStatus:

// Iteration 3.
export type OkStatusCode = 200 | 201 | 202 | ...;
export type ClientErrorStatusCode = 400 | 401 | 403 | ...;
export type ServerErrorStatusCode = 500 | 501 | 502 | ...;
export type StatusCode = OkStatusCode | ClientErrorStatusCode | ServerErrorStatusCode;
export type NonOkStatusCode = Exclude<StatusCode, OkStatusCode>;

export type FetchResponse<TStatus extends StatusCode, TBody> = {
    ok: TStatus extends OkStatusCode ? true : false;
    status: TStatus;
    statusText: string;
    body: TBody
};

export type FetchResult<T, TStatus extends StatusCode, TBody> = 
    T | FetchResponse<TStatus, TBody>;
Enter fullscreen mode Exit fullscreen mode

We have arrived to a series of types that are just beautiful: By branching (writing if statements) based on conditions on the ok or the status property, TypeScript’s type-narrowing function will kick in! If you don’t believe it, let’s write the class part and try it out:

export class DrFetch<T> {
    for<TStatus extends StatusCode, TBody>() {
        return this as DrFetch<FetchResult<T, TStatus, TBody>>;
    }
}
Enter fullscreen mode Exit fullscreen mode

Test-drive this:

const x = new DrFetch<{}>(); // Ok, having to write an empty type is inconvenient.
const y = x
    .for<200, { a: string; }>()
    .for<400, { errors: string[]; }>()
    ;
/*
y's type:  DrFetch<{
    ok: true;
    status: 200;
    statusText: string;
    body: { a: string; };
}
| {
    ok: false;
    status: 400;
    statusText: string;
    body: { errors: string[]; };
}
| {} // <-------- WHAT IS THIS!!!???
>
*/
Enter fullscreen mode Exit fullscreen mode

It should now be clear why type-narrowing will be able to correctly predict the shape of the body when branching, based on the ok property of the status property.

There’s an issue, however: The initial typing of the class when it is instantiated, marked in the comment block above. I solved it like this:

// Iteration 4.
export type FetchResult<T, TStatus extends number, TBody> = 
    unknown extends T ?
    FetchResponse<TStatus, TBody> : 
    T | FetchResponse<TStatus, TBody>;

export class DrFetch<T = unknown> { ... }
Enter fullscreen mode Exit fullscreen mode

This small change effectively excludes the initial typing, and we are now in business!

Now we can write code like the following, and Intellisense will be 100% accurate:

const fetcher = new DrFetch();
...
const response = await fetcher
    .for<200, ToDo>()
    .for<400, { errors: string[]; }>()
    .fetch('api/todos/123', { method: 'PATCH' })
    ;

if (response.status === 200) {
    // You'll have full TypeScript support on the properties of the ToDo object
    // in the body property.
    response.body.id;
}
else {
    // You'll have full TypeScript support here, too:
    response.body.errors;
}
Enter fullscreen mode Exit fullscreen mode

Type-narrowing will also work when querying for the ok property.

If you did not notice, we were able to write much better code by not throwing errors. In my professional experience, axios is wrong, ky is wrong, and any other fetch helper out there doing the same is wrong.

Conclusion

TypeScript is fun, indeed. By combining TypeScript and fluent syntax, we are able to accumulate as many types as needed so we can write more accurate and clearer code from day 1, not after debugging over and over. This technique has proven successful and is live for anyone to try. Install dr-fetch and test-drive it:

npm i dr-fetch
Enter fullscreen mode Exit fullscreen mode

A More Complex Package

I also created wj-config, a package that aims towards the complete elimination of the obsolete .env files and dotenv. This package also uses the TypeScript trick taught here, but it joins types with &, not |. If you want to try it out, install v3.0.0-beta.1. The typings are much more complex, though. Making dr-fetch after wj-config was a walk in the park.

Fun Stuff: What’s Out There

Let’s see a few of the errors out there in fetch-related packages.

isomorphic-fetch

You can see in the README this:

fetch('//offline-news-api.herokuapp.com/stories')
    .then(function(response) {
        if (response.status >= 400) {
            throw new Error("Bad response from server");
        }
        return response.json();
    })
    .then(function(stories) {
        console.log(stories);
    });
Enter fullscreen mode Exit fullscreen mode

“Bad response from server”?? Nope. “Server says your request is bad”. Yes, the throwing part itself is terrible.

ts-fetch

This one has the right idea, but unfortunately can only type OK vs non-OK responses (2 types maximum).

ky

One of the packages that I criticized the most, shows this example:

import ky from 'ky';

const json = await ky.post('https://example.com', {json: {foo: true}}).json();

console.log(json);
//=> `{data: '🦄'}`
Enter fullscreen mode Exit fullscreen mode

This is what a very junior developer would write: Just the happy path. The equivalence, according to its README:

class HTTPError extends Error {}

const response = await fetch('https://example.com', {
    method: 'POST',
    body: JSON.stringify({foo: true}),
    headers: {
        'content-type': 'application/json'
    }
});

if (!response.ok) {
    throw new HTTPError(`Fetch error: ${response.statusText}`);
}

const json = await response.json();

console.log(json);
//=> `{data: '🦄'}`
Enter fullscreen mode Exit fullscreen mode

The throwing part is so bad: Why would you branch to throw, to force you to catch later? It makes zero sense to me. The text in the error is misleading, too: It is not a “fetch error”. The fetching worked. You got a response, didn’t you? You just didn’t like it… because it is not the happy path. Better wording would be “HTTP request failed:”. What failed was the request itself, not the fetching operation.

Top comments (0)