DEV Community

Miloš Šikić
Miloš Šikić

Posted on • Updated on

Error Handling with the Either type

Why?

Have you ever gotten a PR to review where you see exceptions being thrown everywhere, and unless you pull up the whole project, you've no idea if those exceptions are being handled or not? An example:

class UserRepository {
    // ...

    async saveUser(user: User) {
        try {
            await internalRepo.save(user);
        } catch (error) {
            throw new Error("Failed to save the User to the database!");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Did that maybe worry you, because if the exception isn't handled somewhere, your program either crashes or it goes to the global exception handler, which informs a person on-call that you tried to divide by zero (or that a repo call failed for some reason)?

Have you seen code that’s enveloped in a large try/catch block and when it gets to the part where the error needs to be handled, all the different errors are conflated into one? Like this:

class InvitationService {
    ...

    async reinviteWithNewEmail(currentInvitationId: InvitationId, newEmailAddress: EmailAddress) {
        try {
            await invitationRepository.markInvitationAsInvalid(currentInvitationId);

            const newInvitation = Invitation.create({ /* the rest of the needed data..., */ emailAddress: newEmailAddress });
            await invitationRepository.save(newInvitation);

            await emailService.sendInvitationEmail(/* ... */);

            return newInvitation.invitationId;
        } catch (error) {
            // log a generic error or something like that
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Yeah, that's not a nice experience. And there are many ways to solve the problem. This is just one of them.

What are we talking about?

Do you remember any checked exceptions from Java? I mean, if you work with Java, you probably encounter them here and there, though hopefully rarely. People hate them. And why? Well, mostly because they force you to write a try/catch block and deal with errors that you sometimes don't know how to handle. And when you put that together with people writing code that throws exceptions for things that don't need to be exceptions, you get a very ugly call site with a catch block that usually ignores the error. This is the main problem with checked exceptions, in my opinion. They don't give you much flexibility. However, the idea of having explicit error states is good.

Is there a more flexible solution that retains this explicitness?

Well, yes, I'd say that Either provides just that. You get the ability to have explicit errors for all the states you want to define, and at the same time have the flexibility to not handle an error if it's not important to you. Thereby you avoid try/catch blocks everywhere and still stay safe, because you have to check if the result you get back is at least correct.

What does this look like in practice?

I’ll use TypeScript to demonstrate most of the examples today, although I think every language has their own way of implementing the class. Also, this is my adapted version, but there are libraries that come with a full suite of other FP types that you might like.

The type Either is kept very simple

type Either<T, U> = Left<T> | Right<U>;
Enter fullscreen mode Exit fullscreen mode

The main “logic” belongs to the Left and Right classes. In case you're wondering why these names - it's just the standard naming convention used in FP. A Left value indicates that something has gone awry, while a Right value represents the successful path - the “right” answer.

Let’s take a look at the Left class.

class Left<T> {
  readonly error: T;

  private constructor(error: T) {
    this.error = error;
  }

  isLeft(): this is Left<T> {
    return true;
  }

  isRight(): this is Right<never> {
    return false;
  }

  static create<U>(error: U): Left<U> {
    return new Left(error);
  }
}
Enter fullscreen mode Exit fullscreen mode

It’s just a value holder, and that value can be of any type. If you’re wondering what the this is Left<T>, that’s called a type predicate, and it’s used to make type guards that autocast whatever is sent to them. We’ll see that in action later, it’s core to this implementation.

Onto the implementation of the Right class. It’s very similar. In fact, you’ll notice that the only difference is in the name of the single field that each class has: Left has an error field, while Right has a value field. Other than the type guard implementations, the rest is the same.

class Right<T> {
  readonly value: T;

  private constructor(value: T) {
    this.value = value;
  }

  isLeft(): this is Left<never> {
    return false;
  }

  isRight(): this is Right<T> {
    return true;
  }

  static create<U>(value: U): Right<U> {
    return new Right(value);
  }
}
Enter fullscreen mode Exit fullscreen mode

Here’s an example of how to use this new type in your code. Let’s say we have a function that’s supposed to return all cat types from an API.

async function getCatTypes(): Promise<CatTypes> {
    try {
        // call the API to get the data
    } catch (error) {
        // either log an error or rethrow the exception with more info
    }
}
Enter fullscreen mode Exit fullscreen mode

That’s one way of implementing that. Let’s convert it into something that returns Either.

enum ApiError {
    GenericApiError,
}

async function getCatTypes(): Promise<Either<ApiError, CatTypes>> {
    try {
        const { data } = // get it from the API response; also, let's assume that this is typed

        return Right.create(data);
    } catch (error) {
        return Left.create(ApiError.GenericApiError);
    }
}
Enter fullscreen mode Exit fullscreen mode

And at the call site, you would switch from this

// oops, the error is either unhandled in the function, in which case our app breaks
// or, it's handled and we log something, but we display nothing to our user
// or, we just show "Something went wrong" via a global handler (no way 
// to easily assert what went wrong)
const catTypes = await getCatTypes();
Enter fullscreen mode Exit fullscreen mode

To our way of solving it with an Either.

const catTypesOrError = await getCatTypes();

catTypesOrError.value // error, you can't do this, you don't know if this value is of type Right

// you have to check for the return type explicitly
if (catTypesOrError.isLeft()) {
    // oh, something went wrong; handle it and return
    return;
}

// because of the way we have defined Either, from this point on, the compiler
// knows that the type of catTypesOrError is Right, which means you can do:
console.log(catTypesOrError.value); // this now does not error
Enter fullscreen mode Exit fullscreen mode

We have introduced a higher level of determinism into our app now. The caller knows that the function that was called can either error out or return the appropriate value, there’s no second guessing whether there’s a handled or unhandled exception lurking in there, and they don’t have to go read the function in order to determine that (less context switching is always a win).

But now I always have to assert the return type

Yes, and I argue that that’s a good thing. If an error happens, you most likely want to handle it in some way. There are cases where you only care about the correct value. For example, a piece of data that is not mission critical and is updated frequently: you don’t care if you miss an update or two, because you know it will refresh with the next update, or the one after that. In that case, Either does not bog you down, you can only do an assertion to check whether the return type is Left, and return at that spot. Or you can assert that the return type is Right and only do something if that’s the case. In any case, you don’t have to write a try/catch because there’s a checked exception on your method (coming back to that Java example), whether you care about it or not. You just have to check the return value of the function, and feel safe if you got back something in a Right container. You’ll know the value is just right in that case... (I’m sorry, I had to...)

We lost information about the error

Yes, in the example I made, we did. However, you can return an Error object inside of Left, but I advise against it. Here’s why.

This is personal preference, and if returning Errors works for you, feel free and enjoy yourself. But I think that we’re missing a chance to more deeply inspect what the error is at the call site. Let’s take this as an example:

export enum CustomerPreferencesApiError {
    BadUsername,
    UserNotFound,
}

async function getCustomersPreferences(customerUsername: Username): Promise<Either<CustomerPreferencesApiError, CustomerPreferences>> {
    try {
        // call the endpoint to retrieve the preferences
    } catch (error) {
        // we assert the type of the error and figure out that its an HTTP error
        switch (error.status) {
            case 400:
                // This happens when the username is malformed 
                // (i.e. has 7 chars, but all usernames have 8)
                return Left.create(CustomerPreferencesApiError.BadUsername);
            case 404:
                // The user with that username is not found
                return Left.create(CustomerPreferencesApiError.UserNotFound);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This is just an example, but now, when somebody calls this method, they can inspect the type of the error.

const userPrefsOrError = await getCustomersPreferences(username);

if (userPrefsOrError.isLeft()) {
    // we can now easily assert the error
    switch (userPrefsOrError.error) {
        case CustomerPreferencesApiError.BadUsername: {
            // inform the user that they need to re-check the username they entered
            return;
        }
        case CustomerPreferencesApiError.UserNotFound: {
            // inform the user that a user for that username does not exist
            return;
        }
    }
}

// if we got here, we have the prefs and can do whatever we want with them
Enter fullscreen mode Exit fullscreen mode

As you can see, the API call is now much more expressive, and you have full control over how you define error states and what they mean for you. You can go in great detail, or no detail at all, and the caller can later decide what info matters to them. And this gets even more powerful when you have a call site that calls multiple things that can fail, and then it returns its own errors. Check out the service from the beginning of this article that invalidates the current invitation sent to a user, creates a new one, and notifies the user via the newly provided email.

enum ReinviteError {
    FailedToInvalidateCurrentInvitation,
    FailedToCreateNewInvitation,
    FailedToSendOutInvite,
}

class InvitationService {
    ...

    async reinviteWithNewEmail(currentInvitationId: InvitationId, newEmailAddress: EmailAddress): Either<ReinviteError, InvitationId> {
        const result = await invitationRepository.markInvitationAsInvalid(currentInvitationId);

        if (result.isLeft()) {
            return Left.create(ReinviteError.FailedToInvalidateCurrentInvitation);
        }

        const newInvitation = Invitation.create({ /* the rest of the needed data..., */ emailAddress: newEmailAddress });
        const invitationCreated = await invitationRepository.save(newInvitation);

        if (invitationCreated.isLeft()) {
            return Left.create(ReinviteError.FailedToCreateNewInvitation);
        }

        const notificationResult = await emailService.sendInvitationEmail(/* ... */);

        if (notificationResult.isLeft()) {
            switch (notificationResult.error) {
                case InvitationEmailError.ApiException:
                case InvitationEmailError.UnreachableAddress: 
                    return Left.create(ReinviteError.FailedToSendOutInvite);
                case InvitationEmailError.QueuedMessageBecauseOfCongestion:
                    // we're fine with this, it's not urgent to send it right away
                                        // maybe we'll log this out, or set a special status in the DB
                                        // do note there's no return here, we want to fall through

            }
        }

        return Right.create(newInvitation.invitationId);
    }
}
Enter fullscreen mode Exit fullscreen mode

And if this service is later called from a controller, the controller has a lot of info about what to do about an error.

Do we have to return an enum?

Absolutely not. You can return string type unions, objects (custom defined errors with more info, for example), or whatever you like. I just use enums because they’re super simple to define and use. I know some people don’t like using enums in TypeScript, hence the mention. You could definitely use something like this:

class BaseError {
    message: string;
}

class ApiFailureError extends BaseError {
    statusCode: number;
}

class UserNotFoundError extends BaseError {
    username: Username;
}

// to define a return type for either, you could make a union type
type UserApiError = ApiFailureError | UserNotFoundError;

// and then later you can use it as Either<UserApiError, ...>
Enter fullscreen mode Exit fullscreen mode

Using more complex objects instead of strings or numbers is very encouraged if you have important error data to return. But always remember, each return value should represent one error, don’t conflate multiple errors into one just because you have a class that supports that - that’s Either's job.

What if we don’t know what an exception’s structure looks like?

This one is unpleasant, for sure. One way that could allow you to figure out what the exception looks like would be to ask the relevant person in your organization whether it would be ok to log out errors (if you use a log aggregator somewhere, it doesn’t work if you can’t see the log). But be very careful with this, as errors can contain private data that should not leak out anywhere. Use it as a last resort if there’s really no way to figure out how an exception might look like (i.e. from the documentation, Stackoverflow, etc.)

So, in essence, you would be doing everything we mentioned already, but in the catch block, instead of just returning a Left, you would also log out the error in order to learn what it looks like, and then implement a way to handle the exception properly from what you just learned.

Do not do this without asking somebody if it’s ok first, because of laws such as the GDPR which can put your company in a very uncomfortable position (and probably get you fired). First ask, then do.

Can we return Left only from a catch block?

Not necessarily, no. There are APIs that do not throw. Some return a tuple, similar to Either. Some errors are context dependent. Depending on the use case, they may or may not represent an error. Look at this example:

async updateUser(user: User): Promise<Either<RepoErrors, void>> {
    try {
    // update the user row with a call to a TypeORM repo

        if (updateResult.affected === 0) {
            return Left.create(RepoErrors.NoDataAffected);
        }

        return Right.create(undefined);
    } catch (err) {
        return Left.create(RepoErrors.UnknownError);
    }
}
Enter fullscreen mode Exit fullscreen mode

While testing, I found a problem in the code. I was getting a Left value back, while I expected a Right, and I didn't know why. After adding the error type NoDataAffected, I found that I'd tried to update a User with the wrong search criteria, which always failed.

In other cases, this may not be an error because it's okay if something doesn't update (it depends on the business rule).

The important thing here's that you stay consistent. Either you do this, or you don't, otherwise you make a frail API that developers can't rely on. Either every repository returns a Left when no rows are affected, or no repository does.

Do we have to have a return type?

This is a very good question. I've come across several methods that don't return anything useful and instead just trigger side effects in the system and change its state.

In those cases, I used this:

Either<..., void>
Enter fullscreen mode Exit fullscreen mode

When it came to returning a Right value at the end, I returned Right.create(undefined).

I’m not particularly thrilled with this solution, but it works. Until I find a better one, I’ll keep using it, because it’s more useful to handle errors properly than to have code that looks nice.

Testing with Either

For testing with Either, I like to define a utility function that helps with unfurling the result. Do not use this outside of tests if you don’t want to be back at square zero.

export function unsafelyUnfurlEither<T, U>(either: Either<T, U>): U | never {
  if (either.isLeft()) {
    throw new Error('Either did not contain a value!');
  } else {
    return either.value;
  }
}
Enter fullscreen mode Exit fullscreen mode

This allows you to do stuff such as this in the test

const userOrError = await userRepository.findByUsername(username);

const user = unsafelyUnfurlEither(userOrError);
expect(user.status).toBe(UserStatus.Registered);
Enter fullscreen mode Exit fullscreen mode

If unsafelyUnfurlEither runs upon a Left value, it will throw, otherwise, it will return the result, and in the above test, if something gets thrown, the test will fail, which is fine, because then you know you need to fix it. You didn’t just accidentally take all the money from someone in production. And this saves you from doing the isLeft() and isRight() assertions in tests, which don’t really belong there most of the times.

I like pattern matching a lot more than what you’ve shown in this article

I understand, and I agree. But this post is aimed at developers who are mostly still working in imperative paradigms, and therefore do not have access to pattern matching in their environment. Pattern matching is definitely the next logical step, alongside monads and other FP stuff. But that’s a whole other topic.

Can we not just inspect our exceptions inside of catch blocks instead of using Either?

You certainly can, but there are two very important considerations:

  1. If the exception is not checked, the compiler will tell you nothing if you decide not to put a try/catch around a method
  2. If an exception is not handled, it has side effects (it crashes your app, goes to the global exception handler and triggers an alert for somebody on-call, goes into the ErrorBoundary in React, etc.)

Therefore, you can, but it’s not as safe, and not worth it in my opinion.

Do we stop using exceptions altogether?

On the whole, I would say yes.

But I see an exception (no pun intended): suppose you are working on a backend project where you have a global exception handler. And let us say your controller receives a request, delegates it to another thing, which in turn delegates to another thing, and then a catastrophic error occurs. There is no point in continuing with the execution anymore, it is simply impossible to continue. In this case, the first thing you need to do is check if you could have detected the problem earlier: For example, are your validations in order? If you are sure that there was nothing you could have done and it is a rare exception case, I would say that in this case it is ok to throw (and rollback first if necessary) and let the global exception handler send an error response to your client.

Also, if you’re using a framework such as NestJS, where throwing errors is a way of returning certain error responses in the client, feel free to do that in the controller. That’s totally fine.

But the basic rule should be: Do you handle a thrown exception with a catch block? Use Either.

Top comments (0)