Promises and
throw
considered harmful
Take for example the code below
function createUser(email: string): User {
if (users.includes(email)) {
throw new Error(`User with email ${email} already exists`)
}
...
return newUser;
}
or the async version
function createUser(email: string): Promise<User> {
return new Promise((resolve, reject) => {
if (users.includes(email)) {
throw new Error(`User with email ${email} already exists`)
}
...
return newUser;
})
}
try {
const result = createUser('email');
...
} catch (error /* error is not typed */) {
...
}
There's no way for whomever calls any of those functions to know that the function call might result in an error and what type of error to expect - without calling the function and seeing the result at runtime. Or inspecting the source code, most especially when dealing with third party libraries.
Which is why I call functions like that Schrodinger's function, you'll never know if they error or not until you call them or inspect them.
I try to avoid directly returning promises for async operations (most especially async operations that may fail), because promises can reject without any indication what the error will be and are hard to
type
the error value.
So a principle I go by is to never reject a promise or throw an error. One might say "but those were added to JavaScript for a good reason", yes, but they also make your code unpredictable and hard to type check. For Typescript users, you'd have to resort to type casting and if statements to assert the error type.
Solution
The approach I take is to return a Result
, that can be one of two types, an Err
or an Ok
result. Essentially, the promise resolves with either an Err
or an Ok
value. E.g
type Err<T> = { _tag: "Err"; value: T };
type Ok<T> = { _tag: "Ok"; value: T };
type Result<E, A> = Err<E> | Ok<A>;
We've declared our Result
type as a union of Err
and Ok
. So for example, we can write
try {
let file = await readFile(...);
return {_tag: "Ok", value: file}
} catch (err) {
return {_tag: "Err", value: err}
}
We now have a fully typed return value for our async operation, calling this function gives us
let result = ... // {_tag: "Err", value: ...} | {_tag: "Ok", value: ...}
if (result._tag === "Err") {
// value is of whatever type we defined as error
}
So now let's rewrite those functions we saw earlier
function createUser(email: string): Result<Error, User> {
if (users.includes(email)) {
return {_tag: "Err", value: new Error(`User with email ${email} already exists`)}
}
...
return {_tag: "Ok", value: newUser};
}
...
And some utility functions to help us distinguish between the result types
function isErr<E, A>(result: Result<E, A>): result is Err<E> {
return result._tag === "Err";
}
function isOk<E, A>(result: Result<E, A>): result is Ok<A> {
return result._tag === "Ok";
}
And finally, calling our functions
const result = createUser("email");
if (isErr(result)) {
console.log(result.value); // Error()
}
// our result is `Ok` here
console.log(result.value); // User
This is just a quick run through of what is called an Either
or Maybe
in functional programming. If you're interested in diving deep into other ideas like this, checkout fp-ts