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:
Only care about the happy path: The response body when the HTTP status code is 2xx.
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."
]
}
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;
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; }>.
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.
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>;
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>>;
}
}
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!!!???
>
*/
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> { ... }
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;
}
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
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);
});
“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: '🦄'}`
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: '🦄'}`
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)