← History

Exceptions, Results, and Panics: error-handling philosophies

How six languages answer one question: when something goes wrong, is failure a value you return or a control-flow event you throw?

GoRustHaskellOCamlPythonGuji

Every language has to answer the same question: when an operation cannot deliver what it promised, what happens next? The answers split roughly into two camps. In the first, failure is a value - a thing you return, inspect, and pass around like any other. In the second, failure is a control-flow event - a thrown exception that unwinds the stack until something catches it. Most real languages mix the two, but where they draw the line says a lot about their values.

Failure as a value

Go is the plainest expression of "failure is a value." There is no try/catch in idiomatic Go. Functions that can fail return an extra error, and the caller checks it inline:

f, err := os.Open(path)
if err != nil {
    return fmt.Errorf("open %s: %w", path, err)
}

The famous verbosity - if err != nil repeated down every function - is the deliberate cost of making every failure visible at its call site. Nothing is hidden in an invisible unwinding path. Go does keep one escape hatch, panic/recover, but the convention is firm: panics are for programmer bugs (a nil dereference, an impossible state), not for expected, recoverable conditions. A library that panics across its API boundary is considered badly behaved.

Rust takes the value-based idea and gives it teeth in the type system. Fallible operations return Result<T, E>, and absence returns Option<T>. The compiler will not let you reach the success value without confronting the failure case:

fn parse_age(s: &str) -> Result<u32, ParseIntError> {
    let n: u32 = s.parse()?;
    Ok(n)
}

The ? operator is the key ergonomic move. It unwraps an Ok/Some or returns the Err/None from the enclosing function early, collapsing Go's if err != nil ritual into a single character while keeping the failure in the type signature. Rust still has panic! (and unwrap, which panics on None/Err), reserved, like Go's panic, for bugs and unrecoverable invariant violations rather than ordinary error flow.

Guji, an in-house functional-first language, sits firmly in this camp and pushes it a step further by removing the escape hatch for recoverable errors entirely. The spec is blunt: "guji has no exceptions." Operations that may be absent or may fail return one of two standard sum types, Option[T] or Result[T, E], and a postfix ? propagates them:

sub parse_age($s: Str): Result[Int, Str] {
    $n = parse_int($s)?
    if $n >= 0 { Ok($n) } else { Err("age must be non-negative") }
}

Running this confirms the semantics: feeding it "42" yields the integer, "-3" returns Err("age must be non-negative"), and "hello" short-circuits at the ? with the parser's own Err("invalid integer: hello"). Because match is exhaustive and ? requires a matching carrier and error type, the compiler guarantees you never silently ignore a failure. Guji's one concession to the second camp is panic, which has the type Never and aborts the process with a non-zero exit code. It is strictly for unrecoverable states: a violated invariant, an out-of-bounds index, an unwrap of None. There is, by design, no recover. An out-of-bounds index such as @xs[10] on a three-element list prints panic: index 10 out of bounds (len 3) with its source location and exits non-zero - and that is the end of the program, not a catchable event. The division of labor is clean: Result/Option for failures you expect, panic for bugs you do not.

Failure as a value, the functional lineage

Haskell and OCaml predate Rust's Result and largely inspired it. In Haskell, a computation that might fail has type Either e a (or Maybe a for simple absence), and the real elegance is that these are monads, so sequencing fallible steps composes without manual unwrapping:

parseAge :: String -> Either String Int
parseAge s = do
  n <- readEither s
  if n >= 0 then Right n
            else Left "age must be non-negative"

The do block here is doing exactly what Rust's ? does - threading the Left/short-circuit through automatically - but it falls out of the monad abstraction rather than being a bespoke operator. Haskell, being pure and lazy, also famously cannot let a side effect like "throw" hide inside an ordinary expression; effects live in types. (It still has runtime exceptions in IO, plus the lurking error and bottom for truly exceptional cases, but pure failure is a value.)

OCaml is more pragmatic and keeps a foot in each camp. It has had exceptions since the start - fast, native, idiomatic for genuinely exceptional control flow - and uses them freely:

let parse_age s =
  let n = int_of_string s in   (* raises Failure on bad input *)
  if n >= 0 then n else failwith "age must be non-negative"

But modern OCaml increasingly favors result and option types for expected failures, mirroring Rust and Haskell, while reserving raise/try ... with for the exceptional. The standard library even offers paired APIs: List.find raises Not_found, while List.find_opt returns an option. OCaml trusts the programmer to pick the right tool, where Guji and Rust legislate it.

Failure as control flow

Python is the clearest representative of the second camp. Exceptions are not an escape hatch; they are the primary mechanism, woven so deeply that it is idiomatic to use them for ordinary control flow:

def parse_age(s):
    n = int(s)            # raises ValueError on bad input
    if n < 0:
        raise ValueError("age must be non-negative")
    return n

try:
    print(parse_age("hello"))
except ValueError as e:
    print("caught:", e)
finally:
    print("cleanup runs")

The community even has a slogan for this style: EAFP, "easier to ask forgiveness than permission" - try the operation and catch the failure, rather than checking preconditions first. Iteration itself ends with a caught StopIteration. The finally clause guarantees cleanup regardless of how the block exits. The cost is the inverse of Go's: failures are invisible at the call site. A function's signature tells you nothing about what it might raise, so the discipline of knowing which exceptions can surface lives in documentation and habit, not in the type checker.

The real axis: visible or invisible

Lined up, the six languages form a spectrum, not two boxes. Python puts failure entirely in the control-flow layer and out of the type signature. Go and Guji put it entirely in the value layer and demand it be handled at the call site, with a narrow panic carve-out for genuine bugs. Rust and Haskell match Go and Guji on values but let the type system enforce handling and let abstractions (?, do) erase the boilerplate. OCaml deliberately straddles, offering both and trusting taste.

The deepest trade-off is visibility versus convenience. Value-based handling makes every failure show up in the type, which is honest but, without ? or monads, noisy. Exception-based handling keeps the happy path clean but pushes the failure modes into the dark, where they surface at runtime in places you did not expect. Larry Wall once observed that the right design makes "easy things easy and hard things possible." Each of these languages bets differently on which kind of failure should be the easy one - and you can feel that bet in every function you write.