Hitting a Wall

Not everything succeeds. A file might not exist. A network might be down. A move might be illegal. Division by zero is undefined.

When an operation cannot complete, the system must respond. This chapter explores how systems handle failure---from crude crashes to elegant recoveries.

The Simplest Response: Crash

The most basic response to failure is to stop. Give up. Crash.

ruby
    divide(a, b):
        if b == 0:
            CRASH: "Cannot divide by zero!"
        return a / b

This is sometimes appropriate. If an invariant is violated---if something "impossible" happened---crashing might be the only honest response. The system is in an unknown state; continuing could make things worse.

But crashing is drastic. For ordinary failures---a user typo, a missing file---we want something gentler.

Returning Special Values

A step up from crashing is returning a special value that means "failure":

ruby
    find_piece_at(board, square):
        board[square]          // might be a piece
            OR null            // might be "nothing"

The caller must check for this special value:

ruby
    piece = find_piece_at(board, e4)
    if piece == null:
        // handle missing piece
    else:
        // use piece

The problem? It's easy to forget the check:

ruby
    piece = find_piece_at(board, e4)
    piece.color    // CRASH if piece is null!

The type system doesn't help us. piece looks like a normal Piece, but it might be null. We've mixed valid values with a special invalid one.

Exceptions: Jumping Out

Many languages provide exceptions---a way to "throw" an error that interrupts normal execution:

ruby
    validate_move(state, move):
        if not is_valid_move(state, move):
            throw IllegalMoveError("Cannot move there")
        return apply_move(state, move)

When an exception is thrown, the program "jumps out" of the current function---and keeps jumping until something "catches" it:

yaml
    try:
        new_state = validate_move(state, move)
        // ... continue if successful ...
    catch IllegalMoveError as error:
        // ... handle the failure ...
        display("Invalid move: " + error.message)

Exceptions have advantages:

  • Failures can't be silently ignored
  • Error handling code is separate from normal code
  • Errors propagate automatically up the call stack

But exceptions have a cost: they create invisible control flow. Looking at code, you can't easily tell where exceptions might occur. Even when declared in signatures, they create an alternate path that bypasses normal return---a cross-cutting concern that affects many parts of the system.

Effects: Beyond Pure Computation

Exceptions reveal a deeper concept: effects. So far, our functions have been pure---they take inputs and produce outputs, nothing more.

But exceptions break this purity. A function that throws doesn't just produce a value; it can also produce an interruption. It affects the world beyond its return value.

ruby
    // Pure function:
    add(a, b):
        return a + b      // always returns, predictable
    
    // Impure function (has effects):
    divide(a, b):
        if b == 0:
            throw Error   // might not return at all!
        return a / b

Effects make functions harder to compose. If f might throw and g might throw, does f(g(x)) throw? When? Which error?

A Thought Experiment

What if we could handle failure without effects? What if instead of throwing or returning null, we returned a value that explicitly represents possibility of failure?

\marginnote{This is the key insight of functional error handling: make failure a normal value, not a special case. It doesn't break the rules---it plays in the same field as every other value. All the ideas we've developed for values and functions apply to it: we can pass it, return it, compose it.

ruby
    find_piece_at(board, square):
        if board[square] is empty:  Nothing
        else:                       Just(board[square])

The return type is now:

    Maybe Piece:
        Nothing OR
        Just(piece: Piece)

Now the type system forces us to handle both cases. We can't accidentally use a "nothing" as if it were a piece:

    result = find_piece_at(board, e4)
    
    // result.color    // Error! result is Maybe Piece, not Piece
    
    match result:
        case Nothing:
            "no piece found"
        case Just(piece):
            piece.color        // Now we know piece exists

The compiler catches our mistakes. Failure becomes visible. No crashes, no surprises.

This approach---encoding possibility in the type---leads to a beautiful abstraction. In the next chapter, we explore containers of possibility: types that wrap values and represent what might be there.