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.
Diese Seite gibt es noch nicht auf Deutsch, deshalb steht sie hier auf Englisch. Zur englischen Seite
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
Failwithmatch, with??for a fallback, or with the postfix?, which returns theFailfrom the surrounding function early and converts the error type throughFromwhere the two differ. Erroris 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.panicexists next toResult, 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
?andFromdo forResultwithout 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.Erroris 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.
Related#
- Result -
Ok,Fail, and the vocabulary on them. - The question mark operator - early return and the
Fromconversion. - The Error trait -
Result<Value, Error>andcause(). - panic - what a panic prints, and what does not run afterwards.
- Why there is no null - the same argument applied to absence instead of failure.