Why there are no exceptions

A function that can fail says so in its result type, Result<Value, Failure>, and the caller handles it with match, ??, or the question mark operator, while panic stays reserved for bugs that cannot be recovered from.

An exception is a second, invisible return path: nothing in a function's signature says it can throw, or what it can throw, or that a caller ten frames up is the one who has to notice. This page argues for making that path visible instead of hiding it in the control flow.

The decision#

A function that can fail says so in its result type. Result<Value, Failure> is a type with two cases, Ok(value) and Fail(error), and there is no throw, try or catch.

  • A caller handles a Fail with match, with ?? for a fallback, or with the postfix ?, which returns the Fail from the surrounding function early and converts the error type through From where the two differ.
  • Error is a trait, not a base type: Result<Value, Error> is how a layer that only reports a failure accepts any of them, while a library keeps answering its own precise error type.
  • panic exists next to Result, for bugs rather than expected failures, and it is not catchable.
fn parsePort(text: String): Result<Int, String> {
  const value = Int.tryFrom(text).mapError { _ => "not a number" }?
  if value < 1 || value > 65535 {
    return Fail "out of range"
  }
  value
}

fn describe(text: String): String {
  match parsePort(text) {
    Ok(port) => "port {port}"
    Fail(reason) => "invalid: {reason}"
  }
}

print describe("8080")
print describe("nope")

Why#

Because an exception is a control-flow edge that does not appear in the signature. A function typed fn loadConfig(path: String): Config in a language with exceptions might throw IOError, might throw nothing, and a caller finds out by reading the implementation or by being surprised at runtime. fn loadConfig(path: String): Result<Config, IoError> says the same thing in the one place a caller actually reads: the signature.

Because a caught exception loses the type the moment it is caught. catch (Exception e) sees every failure of every callee alike, and telling them apart again means an instanceof chain or a second hierarchy. match on a Result<Value, Failure> only ever sees the Failure the signature names, so the compiler tells a caller when a case was forgotten (see Why every match is exhaustive) instead of a runtime catch finding out.

Because the two return paths of exceptions - "here is the value" and "something happened, unwind" - are one value with two cases here, and one value composes the way every other value does. A Result can be stored in a field, passed to a function, held in a List<Result<Value, Failure>> and folded into a single result with a collection target. An exception can only be thrown and caught, which is why languages with exceptions need a second, separate vocabulary (try/catch/finally) instead of reusing the one they already have for values.

Because panic keeps "this cannot happen" separate from "this can happen and here is what went wrong." An out-of-bounds index or Result.expect on a None is a bug: nothing meaningful can happen next, so the program stops with a message and exit code 101 rather than unwinding through handlers that were written for expected failures. See panic.

What was rejected#

  • Exceptions with a type hierarchy (Java checked exceptions, C#, Python). Rejected because the type of what a function can throw is either absent from the signature or, when checked, propagates through every intermediate frame by hand - exactly what ? and From do for Result without a second syntax.
  • A single concrete error type for everything (anyhow::Error-style). Rejected for the same reason a base exception class is: it is precise for nobody. Error is a trait instead, so precision stays the default and only the "hand it up and report it" layers ask for the trait.

Consequences#

Every fallible call is visible at the call site. There is no line that can fail silently underneath a name that looks like an ordinary function call; a Result is a value the caller holds, and holding it without looking inside is a discarded value.

fn readCount(text: String): Result<Int, String> {
  Int.tryFrom(text).mapError { _ => "not a number" }
}

fn total(values: List<String>): Result<Int, String> {
  var sum = 0
  for value in values {
    sum = sum + readCount(value)?
  }
  sum
}

print total(["1", "2", "3"])

A library keeps its precise error type, and the trait is for the top. fn start(): Result<Void, AppError> lets a caller match on what went wrong; fn main(): Result<Void, Error> is where the only thing left to do with a failure is print it, and cause() walks the chain one link at a time. See The Error trait.

A top-level ? is not a panic. It prints error: <message> and exits with 1, which is different from a panic crashing with 101 - a Result reaching the top of the program is an expected outcome, not a bug. See Errors at the top level.

Esc