Either-like result type in Typescript
Hmmm, Monads and the delicious Either type. Smells functional, smells Haskell, smells Scala, smells complicated. But it isn’t!!!.
Lesson number one:
Stay away from functional terminology. It just messes with you.
The Either type allows you to deal with valid and invalid values in a unified way.
An Either is like a garage with two doors where you can park your car either on the left or on the right side. (and to stop you from thinking about edge cases: no, you don’t have two cars)
So, it is not that complicated. In this context, we will use an Either to create a
result type.
A result type is a union of two things: type Result<T> = T | Error
.
It can be either (ha!, there it is) something of type T
or an Error
. An Either is
just a generalisation: type Either<R,L> = R | L
. You can think of R
as Right and L
as Left. It cannot have R
and L
at the same time.
Either as general result type
This concept is useful for error handling. If you do a fetch()
, you could return a
Result<T>
, which means that error and data of type T
can be returned in one go. But
before we start with that, let’s define a proper result type:
export type Result<T> = T | Error;
export function isError<T>(result: Result<T>): result is Error {
return result && result instanceof Error;
}
export function isSuccess<T>(result: Result<T>): result is T {
return !isError(result);
}
type Person = {
name: string;
};
function getPerson(): Result<Person> {
if (Math.random() > 0.5) {
return { name: "Wurst" };
}
return new Error("kaboom");
}
const res = getPerson();
if (isError(res)) {
console.log(res.message);
} else {
console.log(res.name);
}
This construct gives you the possibility (or power, I might even say) to deal with all kinds of function results that can throw/return errors.
A fetch wrapper could look like this (on purpose this is a very simple wrapper):
export async function resultFetch<T>(url: string): Promise<Result<T | null>> {
return fetch(url, {
method: "GET",
headers: { "Content-Type": "application/json" },
})
.catch((e) => new ConnectionError(stringify(e)))
.then((res) => handleFetchResponse<T>(res));
}
The wrapper returns Result<T | null>
. The null
is there on purpose: it is possible
(and sometimes desired) to return a valid, but empty result. In total three cases are
possible:
- The either contains an object of type T
- No data was returned, but the API call was successful, the either contains
null
- An error occurred, the either contains a
FetchError
Wait a moment, does this Either have 3 options (or garage doors)? Time to dig deeper. In a functional language such as Scala, this Either-construct would correspond to
Result<K> = K | Error; // original definition
// I replaced T with K to prevent confusion
Result<T | null>
~> T | null | Error; // with K = T | null
~> Either[Error, Option[T]] // Scala equivalent
So the answer is: practically yes, conceptually no.
Real-world applications might want to skip the null
part, because in most cases you
expect to have data in an API response.
To check if we have a valid response we have to check success, i.e. not error, and presence of data:
if (isSuccess(res) && res !== null) {
// ...
}
All errors that come directly from fetch are wrapped as ConnectionError
to prevent
that an error is thrown. The goal is to wrap all errors in the Result<T>
type and use
our own (domain) errors for this.
export class FetchError extends Error {
constructor(override readonly message: string) {
super(message);
}
}
export class ConnectionError extends FetchError {}
export class NotFoundError extends FetchError {}
Now we can test the result type by calling our (mock) backend
https://backend.dev/zwei
, which always returns { hello: "world" }
:
const res = await resultFetch<{ hello: string }>("https://backend.dev/zwei");
if (isSuccess(res) && res !== null) {
// The type is correctly inferred
expect(res.hello).toEqual("world");
}
Have fun!
Supplemental functions
I used some functions that are not really essential to the story, but nonetheless useful for understanding:
An example handleFetchResponse<T>
function:
export async function handleFetchResponse<T>(
res: Response | ConnectionError
): Promise<Result<T | null>> {
if (res instanceof ConnectionError) {
return res;
}
switch (res.status) {
case HttpStatusCode.NotFound:
return new NotFoundError(await res.text());
// ...
default:
// Let's take the easy way out: we assume
// everything ok, no error
}
try {
const data = await res.json();
// JSON response, we assume [...] or {...} or null
if (isNil(data)) {
return null;
}
// for the sake of simplicity, type guards and data validation are skipped
return data as T;
} catch (e) {
return new InvalidDataError(stringify(e));
}
}
Utility function to test if the Result<T>
type is defined.
export function isNil(obj: unknown): boolean {
if (obj === undefined || obj === null) {
return true;
}
if (Array.isArray(obj)) {
return false;
}
if (typeof obj !== "object") {
return true;
}
return false;
}
Photo by John Paulsen on Unsplash