Schrodinger's function

Joshua Amaju - Sep 27 '22 - - Dev Community

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;
}
Enter fullscreen mode Exit fullscreen mode

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;
    })
}
Enter fullscreen mode Exit fullscreen mode
try {
    const result = createUser('email');
    ...
} catch (error /* error is not typed */) {
    ...
}
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

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}
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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};
}

...
Enter fullscreen mode Exit fullscreen mode

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";
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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

. . . . . . . . . . . . .
Terabox Video Player