beps/docs/proposals/BEP-001-exceptions/03_alternatives.md
This document explains why we chose Universal Catch over other error handling designs.
function Extract(text: string) -> Result<Resume, Error> { ... }
let result = Extract(text)
match result {
Ok(resume) => ...
Err(e) => ...
}
Rejected because:
Result forces all callers to update their signatures to handle or propagate it.try/catch blocks?// Imperative
function ProcessBatch(urls: string[]) -> Resume[] {
// 1. Hoisting Tax: Declare variable with nullable type
let aggregator: MetricsAggregator | null = null
// 2. Indentation Tax: Wrap initialization
try {
aggregator = MetricsAggregator.new()
} catch {
log.warn("Failed to initialize aggregator")
}
let results = []
for (url in urls) {
let resume = ExtractResume(url)
// 3. Safety Tax: Check for null on every use
if (aggregator != null) {
aggregator.record(resume)
}
results.append(resume)
}
return results
}
// Declarative
function Extract(text: string) -> Resume | null {
try {
// Confusing: Are we "trying" to define the client?
client "gpt-4o"
prompt #"Extract resume from {{ text }}"#
} catch {
e: TimeoutError => null
}
}
Rejected because:
try re-indents every line, breaking git blame and inflating diffs.try are not accessible in catch or after the block, forcing declarations to be moved outside.try implies sequential execution. Wrapping declarative properties like client and prompt in an imperative block creates a semantic mismatch.try an expression (like Kotlin)?let resume = try {
Extract(text)
} catch {
e => null
}
Rejected because:
client and prompt declarations in a try expression.function Extract(text: string) -> Resume try {
client "gpt-4"
prompt #"..."#
} catch {
e => null
}
Rejected because:
function Extract(text: string) -> Resume | null {
try {
return _ExtractInternal(text)
} catch {
return null
}
}
Rejected because:
function Extract(text: string) -> Resume throws TimeoutError, ParseError { ... }
Rejected because:
throws Error everywhere to avoid maintenance, defeating the purpose.vs catch (e) { if/instanceof }:
// Traditional: bind one variable, discriminate inside
try { Extract(text) }
catch (e) {
if (e instanceof TimeoutError) { retry() }
else if (e instanceof ParseError) { return null }
else { throw e }
}
// Pattern matching: discrimination in the syntax
Extract(text) catch {
e: TimeoutError => retry()
e: ParseError => null
}
vs chained catch blocks (Java):
try { Extract(text) }
catch (e: TimeoutError) { retry() }
catch (e: ParseError) { return null }
With chained blocks, catching "everything else" means catch (e) { ... }, which catches panics too. No way to let bugs propagate.
Why untyped patterns exclude Panics:
In match, an untyped pattern matches everything. In catch, an untyped pattern like e matches all Errors but not Panics. Bugs crash loudly by default. To catch panics: p: Panic => ....
Why implicit re-throw:
Unhandled cases propagate automatically. Start by handling one error type, add more as you harden. No else { throw e } boilerplate.
Trade-off: log + rethrow is slightly more verbose:
// Traditional catch (e) - concise for log + rethrow
catch (e) {
log(e)
throw e
}
// Pattern matching - requires a block
catch {
e => {
log(e)
throw e
}
}
But without pattern matching, you can't distinguish Errors from Panics:
// Traditional: catches EVERYTHING, including bugs
catch (e) {
return defaultValue // Swallows IndexOutOfBounds, AssertionError...
}
// Pattern matching: only catches recoverable errors
catch {
e => defaultValue
// IndexOutOfBounds, AssertionError crash immediately - not caught
}
// To handle panics too, be explicit:
catch {
p: Panic => {
log.fatal(p)
throw p
}
e => defaultValue
}
The slight verbosity is the cost of having untyped patterns mean "all errors except bugs."