DEV Community

Anthony G
Anthony G

Posted on • Edited on

Either vs Exception Handling

Love Everyone - No Exceptions

tl;dr

Either solves the problems presented by Exception Handling by adding power and flexibility.

Contents

Why use Either

Why does Task represent both its failure and success cases in data with Either? It seems a bit arbitrary to invent a data type that just represents one thing or another. How did we get here?

Before Either became a common pattern, errors were mainly managed with exception handling. The flaws inherent to exception handling are well documented. In my research, I found that many critical articles pointed to the same few problems.

I've compiled code samples illustrating these problems and alternative code samples showing how Either solves them.

Unchecked Exceptions

Javascript implements exception handling. You've probably seen this kind of thing (adapted from this Joe Fallon article)

let resource: ExampleResource | undefined = undefined;
try {
  resource = new ExampleResource();
  const opened: number = resource.open();
  // use the resource...
} catch (e: any) {
  if (e instanceof Error) {
    // handle error if possible
  } else {
    throw e;
  }
} finally {
  if (resource) {
    resource.close();
  }
}
Enter fullscreen mode Exit fullscreen mode

Javascript exceptions are unchecked. This means that the errors are of the any type (something we normally want to avoid). It also means that exception handling is optional. It's perfectly legal to ignore the possibility of an exception:

const resource = new ExampleResource();
resource.open();
// use the resource...
resource.close();
Enter fullscreen mode Exit fullscreen mode

This is a problem. Users of ExampleResource have no way of knowing whether it could throw an exception other than reading its documentation (assuming it has documentation). They could be in for a surprise runtime error.

Either Provides Types and Compile Time Safety

Here's the same problem, solved with Either

interface ExampleResource {
  open: () => E.Either<string, number>
  close: () => void
}

const getExampleResource = (): E.Either<string, ExampleResource> => ...

const handleResource = (): void => pipe(
  getExampleResource(),
  E.chain((resource: ExampleResource) => {
    const result: E.Either<string, number> = pipe(
      resource.open(),
      E.map((opened: number) => {
        // use the resource ...
      })
    )
    resource.close()
    return result
  }),
  E.fold(
    console.error,
    console.log,
  ),
)
Enter fullscreen mode Exit fullscreen mode

The error type is preserved in the type signature Either<string, number> You can see at compile time exactly what can go wrong. No more any types. You are encouraged to deal with the possible errors by the data structure of Either.

Checked Exceptions

Languages that were designed before Either became popular tried to solve this problem by using checked exceptions. We'll use Java for these examples since it was the first mainstream language to implement checked exceptions and remains the canonical example of them.

If a method throws an error, you're forced at compile time to surround it in a try/catch block, or to propagate it yourself.

public void useResource() {
  ExampleResource resource;
  try {
    resource = new ExampleResource();
    resource.open();
    // use the resource...
  } catch (e: IOException) {
     // handle error
  } finally {
    if (resource != null) {
      resource.close();
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Neglecting an exception is a compile time error:

public void useResource() {
  // Unhandled exception: java.io.IOException
  ExampleResource resource = new ExampleResource();
}
Enter fullscreen mode Exit fullscreen mode

If useResource shouldn't handle the exception, you can choose to propagate it instead using throws

public void useResource() throws IOException {
  ExampleResource resource = new ExampleResource();
}
Enter fullscreen mode Exit fullscreen mode

Checked exceptions are a step in the right direction. They give exceptions type inference and compile-time safety.

However, this approach has a few drawbacks.

Unwanted Information

Although checked exceptions have the advantage of type-safety, often that information is unwanted boilerplate.

catch (IOException e) {
  // 'e' is ignored
  Dialog.alert("Crash when opening resource")
}
Enter fullscreen mode Exit fullscreen mode

This can actually be harmful, as in the case where a low-level method throws a new exception, causing a chain of callers that are now broken at compile time. (Image taken from Checked Exceptions are Evil)

Broken chain of methods

Bill Venners asked Anders Hejlsberg (who would go on to co--found Typescript) about this problem in an interview for artima.com:

Venners: But aren't you breaking their code [by throwing a new exception] anyway?

Hejlsberg: ...No, because in a lot of cases, people don't care. They're not going to handle any of these exceptions. There's a bottom level exception handler around their message loop. That handler is just going to bring up a dialog that says what went wrong and continue.

The programmers protect their code by writing try finally's everywhere, so they'll back out correctly if an exception occurs ... You make sure you protect yourself all the way out by deallocating any resources you've grabbed, and so forth.

...You don't want a program where in 100 different places you handle exceptions and pop up error dialogs. What if you want to change the way you put up that dialog box? That's just terrible. The exception handling should be centralized, and you should just protect yourself as the exceptions propagate out to the handler.

Either Can Eject Unneeded Info

This problem is easily solved by converting Either into Option

const withError: E.Either<string, number> = ...
const errorDoesntMatter: O.Option<number> = pipe(
  withError,
  O.fromEither,
)
Enter fullscreen mode Exit fullscreen mode

Similarity to goto

Cunningham also notes in Don't Use Exceptions for Control Flow that they can be used as a kind of goto statement (goto is considered harmful). Here is his example:

public void search(TreeNode node, Object data) throws ResultException {
  if (node.data.equals(data)) {
    throw new ResultException(node);
  } else {
    search(node.leftChild, data);
    search(node.rightChild, data);
  }
}
Enter fullscreen mode Exit fullscreen mode

The cute trick here is that the exception will break out of the recursion in one step no matter how deep it has got...This makes it harder for programmers to read.

Expression Oriented Control Flow

Since in the above example exception is actually being used to represent optionality, in this case it's actually best replaced by Option

import * as O from 'fp-ts/Option'
import * as E from 'fp-ts/Either'

interface TreeNode<D> {
  data: D
  leftChild: O.Option<TreeNode<D>>
  rightChild: O.Option<TreeNode<D>>
}
const search = <D>(data: D) => (node: TreeNode<D>): O.Option<TreeNode<D>> => pipe(
  node,
  O.fromPredicate(node => node.data === data),
  O.alt(() => pipe(
    node.leftChild,
    O.chain(search(data)),
  )),
  O.alt(() => pipe(
    node.rightChild,
    O.chain(search(data)),
  )),
)

// we can convert `Option` into `Either` (if we want to)
const searchEither = <D>(data: D) => (node: TreeNode<D>): E.Either<string, TreeNode<D>> => pipe(
  search(data)(node),
  E.fromOption(() => 'data not found'),
)
Enter fullscreen mode Exit fullscreen mode

This is an example of expression-oriented programming, as opposed to the Java example which is statement-oriented.

According to Alvin Alexander:

Statements do not return results and are executed solely for their side effects, while expressions always return a result and often do not have side effects at all.

This makes the code easier to read and to reason about, because each operation is labelled and does exactly what it seems like it does.

Even though this code is a one-liner, reading through it we get a sense of things happening one after another. In this sense, alt and chain can be thought of as programmable semicolons: they are sequential in nature.

Difficulty with Callbacks

Here is an example adapted from Ward Cunningham's The Problem with Checked Exceptions

interface Procedure { public void call(); }
abstract class Context {
  public static callWithContext(Procedure proc);
}
...
Context.callWithContext(new Procedure () {
  public void call() {
    InputStream is = ...
    is.read(); // what if `read` throws an exception?
  }
});
Enter fullscreen mode Exit fullscreen mode

How should we handle this? We can't throw the error because Context doesn't know how to handle it. We could put a try/catch block in our implementation of call, but this often isn't ideal - exceptions are often handled similarly across many different situations (e.g. an error dialog box) so this approach could lead to duplicate code.

Duncan McGregor notes that Java-style checked exceptions don't integrate well with inline (lambda) or higher-order functions. The previous example is essentially a different statement of the same problem.

Streams

A callback like this can often be replaced with a data structure called a Stream. Two common kinds of stream in Typescript are generators and rxjs Observables1. Here, we'll use Observable.

The advantage of streams is that they compose together easily. Used in confluence with Either, this solves our above problem - we're able to compose together several streams of error-prone operations, DRY-ly handling all errors in the same place.

import { pipe } from 'fp-ts/pipeable'
import * as E from 'fp-ts/Either'
import * as r from 'rxjs'
import * as ro from 'rxjs/operators'

const numberOutput: O.Observable<Either<string, number>> = new r.Observable((subscriber) => {
  Context.callWithContext(() => {
    const err: E.Either<string, number> = readNumber()
    subscriber.next(err)
  })
})
const otherOutput: O.Observable<Either<string, number>> = ...

// unified errors & successes
const combineStreams: r.Observable<void> = pipe(
  r.merge(numberOutput, otherOutput),
  ro.map(E.fold(
    console.error,
    console.log
  )),
)
Enter fullscreen mode Exit fullscreen mode

Progression

The oversimplified history of exception handling went like this (modified from from Duncan McGregor's Failure is not an Option):

  1. Early APIs returned error codes or set global error flags, but it was easy to forget to check these.
  2. [C++-style] Exceptions were introduced [debatably in Algol 68] to force explicit error handling, but it was still hard to know if a function could fail in practice.
  3. Checked exceptions were introduced [in Java] to make expected failure conditions more explicit.
  4. Checked exceptions [have problems], go back 2 spaces [Unchecked exceptions in C# (kinda), Ruby, Python, Scala, Kotlin, Haskell].

Other Benefits of Either

Compositionality

Not all errors come from the same place - for example, there are validation errors that come from email address input, selecting an invalid date for a person's birthday, or of course, asynchronous failures. Either composes quite nicely no matter where the error came from.


const asyncOp: TE.TaskEither<string, Response> = TE.tryCatch(
  fetch('http://google.com'),
  E.toError
)
const syncOp: E.Either<string, number> = validateEmail(...)

const output = pipe(
  asyncOp,
  T.map(resp => pipe(
    sequenceT(E.either)(
      resp,
      syncOp,
    ),
    E.fold(
      console.error,
      ([resp, validated]: [Response, string]) => {
        // use values safely
      }
    ),
  )),
)

Enter fullscreen mode Exit fullscreen mode

Validations

Either only represents one kind of control flow - a series of operations that short-circuit when any one of them fails. What if we want to continue past an individual failure and collect many possible errors?

Validation is the solution - read more about it here

Either Drawbacks and Pitfalls

Leaky abstraction

In his wonderful article on F#, You're better off using Exceptions, Eirik Tsarpalis argues that Either is a leaky abstraction in a language with unchecked exceptions. Since any operation could fail at any time, we can never ensure full coverage.

Indeed, even a rigid language like Haskell that boasts of referential transparency uses unchecked exceptions. They are an ever-present threat.

And necessarily so. At their most useful, exceptions represent errors that are unexpected, like division by zero or array index out of bounds. Well written code avoids these cases, but of course they are often possible and must be accounted for.

Pokemon Anti Pattern

Tsarpalis writes about this in context of the pokemon anti-pattern, named after the catchphrase "Gotta Catch 'em All"

const simpleArithmetic: (nums: number[]) => number = ...

// since any operation could fail, everything becomes an `Either`
const avg: E.Either<string, number> = E.tryCatch(
  () => simpleArithmetic(nums),
  E.toError
) // gotta catch em all!
Enter fullscreen mode Exit fullscreen mode

This is a problem, since it becomes increasingly difficult to handle errors properly when everything is a potential error.

'Stringly-typed error handling'

Tsarpalis mentions another common anti-pattern - "stringly typed" error handling, a pun on "strongly typed". The pattern is every error sharing the same type: string. This is a problem because differentiating between different kinds of errors based on string comparison becomes messy and fragile.

const operation: E.Either<string, number> = ...
const output = pipe(
  operation,
  E.mapLeft((error: string) => {
    if (error.startsWith('Cannot read property')) {
      console.error('Parse error')
    } else {
      console.error('Other error')
    }
  })
)
Enter fullscreen mode Exit fullscreen mode

In this example, we're relying on our error to start with the exactly correct string. This is messy because different errors might share this attribute, and it's fragile because there might be typos. We also have no compile time guarantees that every possible error has been handled.

The examples in this article so far have used this anti-pattern only for the sake of simplicity. I don't recommend using string to represent errors

Using the Error type provided by Typescript leads to the same problems, since often the only relevant information is stored in message, a string. This is of course also a problem with exception handling, except it's worse because we have an any type instead of string.

You can solve all of these problems by representing your errors with union types instead.

Solution: Union Types

With a union type, you decide ahead of time which errors you care about, and you're able to easily simulate a pattern match using a switch statement.

const enum ErrorType {
  Parse,
  Other,
}

const operation: E.Either<ErrorType, number> = ...
const output = pipe(
  operation,
  E.mapLeft((error: ErrorType) => {
    switch (error) {
      case ErrorType.Parse:
        console.error('Parse error')
        break;
      case ErrorType.Other:
        console.error('Other error')
        break;
    }
  })
)
Enter fullscreen mode Exit fullscreen mode

This way, we're able to choose exactly what granularity we want to handle errors with, and hold ourselves to that later. This is better than stringly-typed errors because we're able to easily differentiate between errors at compile time. If we use a sum type library like @morphic-ts/adt, we get a more strict pattern match with exhaustiveness checking to ensure that we've handled every case.

I've written more extensively about about using sum types and @morphic-ts/adt here, and about the difference between union types and sum types here.

Conclusion

Phil Hauer makes the point in Checked Exceptions are Evil that if we're just logging an error and exiting, it's actually more informative to fail at runtime because we have the added benefit of the printed call stack.

Sometimes there's wisdom in forgoing a more comprehensive yet flawed solution. However, rather than throwing the baby out with the bathwater, Either solves all of the specific problems I could find for exceptions, both unchecked and checked.

I would argue that the advantages of Either outweigh the pain of uncertainty. Every non-trivial abstraction leaks, and I think Either is still better than its alternatives.

This is a common refrain when adopting functional paradigms. Since complete purity is basically unattainable, past what point is it worth striving for anyway? In the case of Either, I think almost always.

However, since they are by definition a compile-time solution they cannot take the place of unexpected runtime exception handling. Hopefully the arguments listed above can help explain the resurgence of unchecked exception handling and its benefits.


  1. rxjs is generally used for pushing changes, while generators are used for pulling them. Generators are better for handling backpressure. source 

Top comments (0)