Error Handling — Cross-Language Comparison
This note compares the error-handling models a programmer actually picks between when designing an API — exceptions vs error-as-value vs Optional/Maybe vs error effects vs panic-and-restart — across all 53 deep language notes in the library. Each section gives the canonical mechanism, its real cost (allocation, control-flow disruption, type-system burden), where it dominates, and where it breaks down. The closing decision tree maps application type and safety requirement onto the right model.
See also
- _compare_memory_models
- _compare_type_systems
- _compare_concurrency
- _compare_metaprogramming
- _compare_build_tools
1. The five families
Five families dominate every language’s error-handling story. Most modern languages mix two or more; the boundary between “exception” and “effect” has blurred since 2018.
| Family | Mechanism | Cost | Languages |
|---|---|---|---|
| Exceptions (unchecked) | throw / catch / finally; stack unwind | high (allocate exception + capture stack + unwind frames) | Python, Java (RuntimeException), JavaScript, C#, Ruby, Kotlin, Scala, Groovy, PHP |
| Exceptions (checked) | declared in signature; compiler enforces | high + signature churn | Java (subclass of Exception not RuntimeException) — only mainstream language with checked |
| Error-as-value | Result<T,E> / Either<E,A> / Go (T, error); explicit unwrap | low (no unwind), high syntactic noise | Rust, Go, Haskell, OCaml, F#, Scala (Try/Either), Swift (Result) |
| Optional / Maybe | Option<T> / Maybe a / T?; null replacement | low (single bit) | Haskell, OCaml, Scala, Swift, Kotlin (T?), Rust (Option<T>), F# |
| Effect systems | declared in type; algebraic effects with handlers | medium (one-shot or multi-shot continuations) | Koka, Eff, Effekt; Effect-TS (TS library); Java JEP 453 structured concurrency partially |
| Panic / abort | crash the unit (thread, process, actor) and let supervisor restart | low (no recovery) | Rust panic, Go panic, Erlang exit, Elixir raise, Pony — Erlang’s “let it crash” philosophy |
| Conditions / restarts | non-local resumable handler | medium | Common Lisp (canonical), Dylan |
| Returning sentinel | -1, NULL, errno | very low + huge footgun | C, COBOL |
| Process / shell exit code | $? | very low | Bash, PowerShell pipelines |
2. Exceptions — unchecked
Throw an object, unwind the stack until a matching handler catches. Allocation + stack capture is expensive (microseconds in Python, hundreds of ns in Java HotSpot post-2018). The control-flow surprise — any line might exit your function — is the design tension.
| Language | Hierarchy root | Try/catch syntax | Notes | Linked note |
|---|---|---|---|---|
| Python | BaseException (then Exception) | try/except/else/finally | exceptions for control flow encouraged (StopIteration); __cause__ chaining via raise X from Y; ExceptionGroup + except* for parallel (PEP 654, 3.11+) | python |
| JavaScript | Error (subclass for TypeError, RangeError) | try/catch/finally; catch (err) { ... } (no type filter without instanceof) | AggregateError for Promise.any; unhandled-rejection event | javascript |
| TypeScript | inherits JS | same | TS does not type the catch clause — caught value is unknown since TS 4.4 default | typescript |
| Java | Throwable → Error (fatal) / Exception (checked) / RuntimeException (unchecked) | try/catch/finally; try-with-resources (AutoCloseable) | only mainstream lang with checked exceptions — must declare in throws; controversial since 2000s | java |
| [[Languages/csharp|C#]] | Exception | try/catch/finally; when filter clause (catch (E e) when (cond) { ... }) since C# 6 | exception filters preserve stack — preferred to if (cond) throw; inside catch | csharp |
| Ruby | Exception (then StandardError) | begin/rescue/else/ensure | bare rescue catches StandardError; raise re-raises | ruby |
| Kotlin | inherits Java Throwable | try/catch/finally; try is an expression (returns value) | no checked exceptions even when calling Java; coroutine errors propagate via CoroutineExceptionHandler | kotlin |
| Scala | inherits Java Throwable | try/catch/finally; catch uses pattern match | Try[A] / Either[E,A] / Validated[E,A] (Cats) are functional-style alternatives | scala |
| Groovy | inherits Java | same | no checked exceptions in Groovy code | groovy |
| PHP | Throwable (PHP 7+) | try/catch/finally; multi-catch catch (A | B $e) since 8.0 | finally runs even after return; Error types separate from Exception | php |
| Perl | die/$@; Try::Tiny/Try::Catch modules | eval { ... }; if ($@) { ... } (legacy) or try { ... } catch ($e) { ... } (5.34+) | core try/catch since 5.34 (2021) | perl |
| Dart | Object (anything can be thrown); idiomatic Exception/Error | try/on/catch/finally (on TypeName catch (e)) | Errors = programmer mistakes (uncaught preferred); Exceptions = runtime conditions | dart |
| Lua | error objects (string or table); pcall/xpcall | local ok, err = pcall(fn, args); no try/catch | xpcall lets you pass a handler before unwind | lua |
| R | conditions (extends Lisp model); tryCatch + withCallingHandlers | tryCatch({...}, error = function(e) {...}) | uses Lisp conditions but exposes as exceptions; restart system available | r |
| Julia | Exception (hierarchy) | try/catch/finally; rethrow() preserves stack | exceptions cheap on JIT — used freely | julia |
| Crystal | Exception | begin/rescue/ensure Ruby-style | typed rescue clauses (compile-time checked) | crystal |
| C++ | any type throwable (idiom: std::exception subclass) | try/catch(...) | RAII handles cleanup; noexcept since C++11; exception cost on miss path significant | cpp |
| Ada | Exception predefined + user-defined | begin ... exception when E => ...; end; | exceptions exist but contract programming (preconditions) preferred | ada |
| Smalltalk | Exception hierarchy | [...] on: Error do: [:e | ...] | full resumable + outer + return + pass | smalltalk |
| Tcl | error code + result | catch { ... } result | try ... finally ... since 8.6 | tcl |
| MATLAB | MException | try/catch ME | MException.identifier for matching | matlab |
The cost of exception throwing in 2026:
- Python: ~5 μs to construct + raise + catch
- Java: ~1 μs for
RuntimeExceptionwithfillInStackTrace; ~100 ns if you overridefillInStackTrace - C++ libstdc++ Itanium ABI: ~10–100 μs (very expensive miss-path); zero-cost on no-throw path
- Go panic + recover: ~1 μs
Exception-as-control-flow (Python’s StopIteration, Smalltalk’s resumable, R’s conditions) is fine; exception-as-error-for-every-failure-path in tight inner loops is not.
3. Checked exceptions — Java’s lonely experiment
Java’s Exception (not RuntimeException) subclasses must be declared in the throws clause and either caught or propagated by every caller. The good: every error path is visible in the API. The bad: signature churn, leaky abstractions, throws Exception catch-all, and the boilerplate that drove Kotlin/Scala/Groovy to disable checked exceptions even on the JVM.
Reaction over time:
- 1995: Java introduces checked exceptions as a discipline.
- 2000s: industry consensus that checked exceptions are mostly an antipattern (Hejlsberg in 2003 anti-checked interview before launching C# without them).
- 2014: Java 8
StreamAPI can’t propagate checked exceptions throughmap/filterlambdas; workarounds proliferate. - 2018+: Kotlin, Scala, Groovy on JVM all skip checked.
- 2022+: Java itself relaxes via
sealed+ pattern matching enabling Result-shaped types; effects (JEP 453 structured concurrency) handle propagation.
Conclusion: checked exceptions remain a Java oddity; no language since has adopted them.
4. Error-as-value (Result, Either, Maybe, Go errors)
Returning errors as ordinary values. The compiler enforces handling (in typed languages) without unwinding the stack. Trade-off: explicit at every call site (? operator in Rust hides this; if err != nil in Go does not).
| Language | Type | Idiomatic syntax | Notes | Linked note |
|---|---|---|---|---|
| Rust | Result<T, E> and Option<T> | ? operator (try-propagate); match; let-else | From<E1> for E2 enables ? conversion; thiserror + anyhow crates dominate libraries vs apps; #[derive(Error)] typical | rust |
| Go | (T, error) tuple | if err != nil { return err }; no try shortcut (rejected proposal Aug 2019) | errors.Is, errors.As, errors.Unwrap; %w verb in fmt.Errorf for wrapping; explicit + boring | go |
| Haskell | Either e a, Maybe a, ExceptT e m a | monadic do-notation; <- propagates Left/Nothing | MonadError typeclass; MonadFail for pattern-match failures; Validation for accumulating | haskell |
| OCaml | ('a, 'b) result, 'a option | let* x = … in with let-binding ops (4.08+); match | exceptions also exist and are used freely (Not_found, Failure); Result for API boundaries | ocaml |
| [[Languages/fsharp|F#]] | Result<'T, 'E>, Option<'T> | computation expression result { let! x = ... }; pipe + Result.bind | C# OptionalFSharp.Core | fsharp |
| Scala | Either[E, A], Try[A], Validated[E, A] (Cats) | for-comprehension for { x <- ... } yield ...; pattern match | Cats Effect, ZIO add typed error channels | scala |
| Swift | Result<Success, Failure> AND throws (both!) | do/try/catch for throws; try?/try! for Optional/force-unwrap; Result for stored | typed throws since Swift 6.0 (2024) — func f() throws(MyError) { ... } | swift |
| Kotlin | Result<T> (stdlib; restricted use) + nullable | runCatching { ... }.fold(success, failure) | Result discouraged for public APIs; nullable + sealed classes preferred | kotlin |
| Erlang | {ok, X} / {error, Reason} tuples | pattern match | this is the canonical Erlang idiom even with try/catch available | erlang |
| Elixir | {:ok, x} / {:error, reason} tuples; with macro | case, with, bang-functions raise (Map.fetch!) | :ok/:error is so canonical that with macro exists to chain | elixir |
| Gleam | Result(t, e); Option(t) | case; try (use op) — let x = try thing() | borrowed Rust Result fully, BEAM runtime | gleam |
| Roc | Result a err | platform handler boundary | ! operator (Roc 2024) for try-style propagation | roc |
| Zig | !T error union (MyError!T) | try for propagation; catch for inline handling | errdefer for partial-cleanup-on-error; error.X enum-like | zig |
| Nim | exceptions OR Option[T] / Result[T, E] (results lib) | both available | exceptions preferred for “exceptional”; Result for “expected” | nim |
| Odin | multi-value return: f :: proc(...) -> (T, Error) | early-return idiom | also has or_return directive | odin |
| Mojo | similar to Rust: Result[T, E] + Python-compatible exceptions | raises annotation; try/except from Python; ! op proposed | bridging Python’s exceptions with Rust’s Result is a Mojo design tension | mojo |
The “Result vs exception” debate maps directly onto does the caller care which error happened. If every caller will handle every error → Result. If most callers want a generic logged-and-bubbled “something broke” → exception.
5. Optional / Maybe — the null replacement
The single biggest reduction in runtime bugs of the 2010s. Tony Hoare’s “billion-dollar mistake” (null references, ALGOL W 1965) — every language since 2010 either makes nullability explicit or types out null entirely.
| Language | Type | Idiomatic | Notes | Linked note |
|---|---|---|---|---|
| Haskell | Maybe a (Just a, Nothing) | do, <$>, `< | >, fromMaybe` | canonical |
| OCaml | 'a option (Some a, None) | match, Option.bind, let* x = ... | canonical | ocaml |
| Scala | Option[A] (Some(a), None) | for, .map, .flatMap, .getOrElse | canonical | scala |
| Swift | T? syntax sugar over Optional<T> (some, none) | if let x = ..., guard let, ?? coalesce, x?.method() chain | language-level support is heavy | swift |
| Kotlin | T? (nullable) — language-level | ?., ?: Elvis, !! force, let { }, smart casts | nullable propagates differently than Optional (no Maybe wrapper) | kotlin |
| Rust | Option<T> (Some, None) | ?, match, .map, .unwrap_or | distinct from Result<T, E> | rust |
| [[Languages/fsharp|F#]] | Option<'T> (Some, None) | computation expression, Option.bind | inherited from OCaml | fsharp |
| [[Languages/csharp|C#]] | nullable reference types (NRT) since 8.0 (2019); Nullable<T> for value types | string?, int?, ??, ?., ! suppress | partial enforcement at compile time via #nullable enable | csharp |
| TypeScript | T | null | undefined (under strictNullChecks) | ?., ??, type narrowing | TS-only; runtime has both null and undefined | typescript |
| Dart | sound null safety (Dart 2.12, 2021) | T?, T!, late T, ??, ?. | full migration from Dart 2.0 nullable everywhere | dart |
| Idris | Maybe a + dependent types let you encode non-null at type level | pattern match | precondition encoded in type | idris |
| Lean | Option α | do, bind | typeclass-based | lean |
| Coq (Rocq) | option A | match, do, option_map | proof obligations may force handling | coq |
| Elm-like Roc / Roc | Maybe a | when | inherits from Elm ancestor | roc |
The 2018–2024 industry trend: nullable-by-default → non-null-by-default with opt-in nullability. C# (NRT 8.0), Kotlin (T? distinction from launch), Dart (sound null safety 2.12), TypeScript (strictNullChecks), even Java (proposed JEP 8303099 null-restricted types, Project Valhalla flow). The benefit is massive — Kotlin/Dart/Swift teams routinely report 90%+ reduction in null-pointer crashes in production.
6. Effect systems — algebraic effects + handlers
A research-language idea (Plotkin + Power 2003; Bauer + Pretnar 2013 Eff) that has crossed into mainstream type-driven libraries.
| Language / library | Mechanism | Status | Notes | Linked note |
|---|---|---|---|---|
| Koka | first-class algebraic effects + handlers; total inference | research lang (Microsoft, Daan Leijen) | model for the field; tracks every effect in type | n/a |
| Eff / OCaml 5 effect handlers | algebraic effects (one-shot) | OCaml 5.0 (2022) added effect handlers for concurrency (Eio + Domainslib) | mainstream production language with effects | ocaml |
| Effekt | second-class effect handlers | research (Tübingen + Sussex) | type-system-only effect tracking | n/a |
| Effect-TS | TypeScript library | mature; v3 released 2024 | Effect<R, E, A> channel — composable error channel + dependency + value | typescript |
| ZIO | Scala library | mature; v2 | ZIO[R, E, A] — same shape as Effect-TS, Scala flavor | scala |
| Cats Effect | Scala library | mature; v3 | IO[A] + tagless-final effect | scala |
| Unison | first-class effects (called “abilities”) | active language | model for distributed code | n/a |
| Haskell | mtl-style monad transformers OR polysemy OR fused-effects OR eff library | mature ecosystem | tagless-final + free-monad are also alternatives | haskell |
Effects unify error, state, async, logging, and resource cleanup under one type-level abstraction. The cost is steeper learning curve and slower compile times in Haskell + Scala. Effect-TS and ZIO have nudged the mainstream-TS/Scala crowd toward effect channels for new microservice code.
7. Panic, abort, supervisor restart — “let it crash”
Cultivated to extreme in Erlang/BEAM (Joe Armstrong’s “fault-tolerant systems are built by isolating crashes, not preventing them”). Errors that violate invariants are not caught — they crash the unit (process, actor, task) — and a supervisor restarts a fresh instance.
| Language | Mechanism | Restart authority | Notes | Linked note |
|---|---|---|---|---|
| Erlang | exit/1, error/1, throw/1; mailbox + link/monitor/supervisor | OTP supervisor (one_for_one, one_for_all, rest_for_one, simple_one_for_one) | the canonical formulation | erlang |
| Elixir | inherits BEAM | OTP supervisor (use Supervisor, DynamicSupervisor); Task.Supervisor | identical model to Erlang | elixir |
| Gleam | inherits BEAM | OTP supervisor via gleam_otp lib | typed Erlang/Elixir lineage | gleam |
| Rust | panic!() and unwrap()/expect() panic; catch_unwind for FFI | thread-level abort (default) or unwind (configurable) | panic is for invariant violation, not error; abort-on-panic preferred for embedded | rust |
| Go | panic() / recover(); goroutine-level | recover in same goroutine; nothing restarts | discouraged for ordinary errors — use (T, error) instead | go |
| Pony | actor-level fault — ? partial functions; no exceptions | runtime catches and treats as recoverable | per-actor fault containment | pony |
| Swift | fatalError, precondition, assert | always crashes | distinct from throws | swift |
| Scala | Akka actor supervisor — same model as BEAM but on JVM | OneForOneStrategy, AllForOneStrategy | mainstream JVM port of OTP model | scala |
| [[Languages/csharp|C#]] | Orleans actor model — same supervisor concept | Orleans grain activation | Microsoft port of OTP-ish for .NET | csharp |
| C | abort(), assert(), _Exit() | terminates process; OS-level | external supervisor (systemd, daemontools, k8s liveness) only | c |
The OTP supervisor tree is the most reliable error handling pattern in production by far. WhatsApp pre-Meta ran 1.5B users on 50 engineers using Erlang OTP. Discord uses Elixir + OTP for the message bus. RabbitMQ + Riak + CouchDB all chose Erlang/OTP for this exact reason.
The supervisor pattern is incompatible with exceptions or Result as the only error-handling mechanism — supervisors require crashable units, which requires a model that says “some errors are not handled, they crash the unit on purpose”.
8. Conditions and restarts — the Lisp lineage
Common Lisp’s condition system (1989) is more powerful than every other model on this list — but its complexity prevented adoption elsewhere.
The Lisp model:
- A condition is signaled (
signal,error,warn,cerror). - The dynamic stack between signaler and handler is still alive — the handler can choose to (a) handle and unwind, (b) decline and let the signal continue up, (c) invoke a restart registered by the signaling site, which resumes execution at the restart point with new data.
- The signaling site provides restarts (
use-value,store-value,continue,abort); the handler picks one.
R + Dylan inherited the model. Smalltalk’s resumable exceptions are a partial port. Modern algebraic effect systems are arguably a typed re-discovery of the same idea.
| Language | Condition lineage | Notes | Linked note |
|---|---|---|---|
| Common Lisp | full Steele 1990 condition system | error/warn/signal + handler-bind/handler-case + restart-bind/restart-case + invoke-restart | common-lisp |
| R | conditions + handlers + restarts | tryCatch, withCallingHandlers, withRestarts | r |
| Smalltalk | resumable exceptions | Exception>>resume: | smalltalk |
| Scheme | R6RS condition system; SRFI-34 + SRFI-35 | implementation varies | scheme |
| Racket | rich exception + delimited continuations + parameterize | most powerful Scheme on this axis | racket |
| Clojure | exceptions only; no restart system | community libraries (e.g. clojure.core/ex-info) for richer info | clojure |
9. Sentinel returns and shell-style exit codes
| Language | Mechanism | Notes | Linked note |
|---|---|---|---|
| C | -1, NULL, errno; setjmp/longjmp for non-local | assert(3) for invariants; goto cleanup; idiom | c |
| COBOL | RETURN-CODE; condition names; declaratives section | classical CICS programs use abend codes + supervisor restart | cobol |
| Fortran | IOSTAT, ERR= label, STAT= | F2008 ERROR STOP; coarray sync errors | fortran |
| Bash | exit code $?; set -e, trap ERR, ||, && | pipefail, errexit, nounset; pitfalls in subshell + pipeline | bash |
| PowerShell | $?, $LASTEXITCODE, try/catch; -ErrorAction Stop promotes non-terminating | non-terminating errors are unique to PowerShell — they print + continue unless you stop them | powershell |
| SQL | SQLSTATE codes; per-dialect raise/exception | PL/pgSQL BEGIN ... EXCEPTION WHEN ... END; T-SQL TRY/CATCH; PL/SQL EXCEPTION WHEN ... | sql |
| Prolog | throw/1 + catch/3 + failure | ISO Prolog exceptions; failure-driven semantics also functions as control flow | prolog |
| Pascal | I/O result variables; Delphi try/except later | classical Pascal uses sentinel; Delphi adds OOP exceptions | pascal |
10. Structured concurrency and error propagation
The 2018+ shift: in a concurrent program, errors don’t belong to a single call stack — they belong to a task tree. Structured concurrency (Trio’s Nathaniel Smith, then Python asyncio.TaskGroup, Kotlin coroutines, Swift TaskGroup, Java JEP 453) enforces that child tasks are confined to a parent scope, and errors in any child propagate to the parent.
| Language | Primitive | Error propagation | Notes | Linked note |
|---|---|---|---|---|
| Python | asyncio.TaskGroup (3.11+); Trio nursery | first error cancels siblings; raises ExceptionGroup | PEP 654 — ExceptionGroup + except* for handling | python |
| Kotlin | coroutineScope { launch { ... } } | first failure cancels siblings; supervisorScope isolates | CoroutineExceptionHandler for unhandled | kotlin |
| Swift | withTaskGroup, withThrowingTaskGroup | thrown error cancels group | typed throws since 6.0 | swift |
| Java | StructuredTaskScope JEP 453 (preview J21, finalized J25) | shutdownOnFailure(), shutdownOnSuccess() policies | first major structured-concurrency for Java | java |
| Rust | Tokio JoinSet, select!, tokio::spawn — no enforced scope until v1.35 task::scope experiment | manual; JoinError returned | not enforced by language — must be designed in | rust |
| Go | errgroup.Group (golang.org/x/sync) | first error cancels via context | de facto standard but not language-level | go |
| Elixir | Task.Supervisor + Task.async_stream; OTP | supervisor restart strategy | OTP supervisor is already structured at the actor level | elixir |
| Scala | Cats Effect Resource / ZIO Scope | typed error channel cancels fibers | functional-effect style | scala |
11. Per-language summary table
| Language | Primary | Secondary | Default for new code | Linked note |
|---|---|---|---|---|
| Python | exceptions | Optional/typing | exceptions + typed Optional | python |
| JavaScript | exceptions | none | exceptions + try/catch | javascript |
| TypeScript | exceptions + T | null | Effect-TS (libs) | strict null + exceptions; Effect for new microservices | typescript |
| Java | exceptions (checked + unchecked) | sealed Result types | unchecked + try-with-resources + structured concurrency | java |
| C | sentinel + errno | setjmp | sentinel + macros | c |
| C++ | exceptions OR tl::expected/std::expected (C++23) | std::optional | std::expected<T, E> for new code; exceptions still common | cpp |
| [[Languages/csharp|C#]] | exceptions | nullable + Result libs | exceptions + NRT 8.0+ | csharp |
| Go | error-as-value (T, error) | panic/recover for invariants | explicit if err != nil | go |
| Rust | Result<T, E> + ? | panic for invariants | ? + thiserror/anyhow | rust |
| Swift | throws + do/try/catch | Result<T, E> (stored); T? | typed throws since 6.0 | swift |
| Kotlin | exceptions | T? + sealed Result | nullable + coroutine CoroutineExceptionHandler | kotlin |
| Ruby | exceptions | Result gems | begin/rescue/ensure | ruby |
| PHP | exceptions (PHP 7+) | none | try/catch/finally | php |
| Scala | Either, Try, ZIO/Cats Effect | exceptions | ZIO/Cats Effect for new microservices | scala |
| Dart | exceptions | sound null safety | try/on/catch + T? | dart |
| Elixir | {:ok, x} / {:error, r} + supervisor restart | exceptions | OTP supervisor + with macro | elixir |
| Haskell | Either e a, Maybe a, monad transformers | runtime IO exceptions | MonadError, MonadFail, MTL or effects | haskell |
| Clojure | exceptions | ex-info data | exceptions + ex-info maps | clojure |
| [[Languages/fsharp|F#]] | Result<'T, 'E>, Option<'T> | exceptions | computation expressions | fsharp |
| Lua | pcall/xpcall | sentinel returns | local ok, err = pcall(...) | lua |
| R | conditions + restarts | tryCatch | tryCatch + withCallingHandlers + condition objects | r |
| Julia | exceptions | none | exceptions; cheap on JIT | julia |
| Zig | error union !T | errdefer | try + errdefer | zig |
| Nim | exceptions OR Result (results lib) | both | exceptions default; Result optional | nim |
| Crystal | exceptions | none | begin/rescue/ensure | crystal |
| OCaml | exceptions + result + effects (OCaml 5) | both equally idiomatic | exceptions for fast path, result for API boundary, effects for concurrency | ocaml |
| Perl | die / $@ / Try::Tiny | none | core try/catch since 5.34 | perl |
| Erlang | exit/error/throw + supervisor | {ok, _} / {error, _} tuples | OTP supervisor + tuples | erlang |
| Racket | exceptions + delimited continuations | conditions | with-handlers | racket |
| Common Lisp | conditions + restarts | none needed | handler-bind/case + restart-case | common-lisp |
| Scheme | conditions (R6RS) + SRFI-34/35 | impl-specific | with-exception-handler | scheme |
| Fortran | IOSTAT + ERR/STAT | F2008 ERROR STOP | IOSTAT + STAT | fortran |
| COBOL | RETURN-CODE + DECLARATIVES + CICS abend | none | sentinel + declaratives | cobol |
| Ada | exceptions + pragma Restrictions + SPARK contracts | none | exceptions; contracts in SPARK | ada |
| Pascal | I/O result; Delphi try/except | none | varies | pascal |
| Prolog | throw/catch + failure | none | catch/3 | prolog |
| Tcl | catch + try/finally | none | try/finally (8.6+) | tcl |
| Groovy | exceptions (Java) | none | try/catch/finally; no checked | groovy |
| Bash | exit code + trap ERR | none | set -euo pipefail + trap | bash |
| PowerShell | non-terminating + terminating; try/catch | -ErrorAction Stop | try/catch + -ErrorAction | powershell |
| SQL | SQLSTATE + per-dialect | none | PL/pgSQL EXCEPTION block | sql |
| V | option ? + result ! | exceptions also | option + result preferred | v |
| Odin | multi-value return + or_return | none | multi-value | odin |
| Roc | Result a err | none | platform-level error channel | roc |
| Gleam | Result(t, e) + try | supervisor restart on BEAM | Result + OTP | gleam |
| Pony | ? partial functions | actor-level fault | partial typing | pony |
| Idris | Maybe + dependent precondition | effect handler libs | encode in type | idris |
| Lean | Option, Except, IO.Error | effect-like monads | monadic | lean |
| Coq (Rocq) | option, sum | proof obligation | type-encoded | coq |
| Agda | Maybe, Either, ⊥ | typed totality | type-encoded | agda |
| Smalltalk | resumable exceptions | none | on:do:; resume/return/pass | smalltalk |
| MATLAB | MException + try/catch | none | try/catch ME | matlab |
| Mojo | raises + Python try/except | Result-style emerging | raises annotation | mojo |
12. The 2024–2026 convergence
Several distinct lineages are converging on a typed two-channel model: one channel for the happy-path value, one channel for typed errors, with cheap propagation.
- Swift 6.0 (2024): typed throws —
func f() throws(MyError) { ... }— same signature shape as RustResult<T, MyError>but with throw syntax. - Java JEP 453 (J21 preview, J25 final):
StructuredTaskScope+ sealed Result types via pattern matching. - TypeScript: Effect-TS v3 normalizes
Effect<R, E, A>channel for libraries; works around TypeScript’s untyped catch. - Scala 3: union types + sealed Result; ZIO 2.x + Cats Effect 3.x are the mainstream paths.
- Python 3.11+
ExceptionGroup+except*— handles parallel errors first-class. - Kotlin 2.x: arrow-kt brings Either/Validated to Kotlin idiomatically; structured concurrency error model matured in coroutines 1.7+.
- Rust 2024 edition + try blocks (still nightly) + Try trait stabilization (in progress).
- C++23:
std::expected<T, E>standardized — formalizes a decades-old library pattern. - Mojo: tries to reconcile Python exceptions + Rust Result at the same site.
The net direction: typed errors at API boundaries, panics for invariants, supervisors for crash containment in concurrent code, structured concurrency to scope error propagation. The exception-vs-Result war is over — they’re complements at different layers.
Adjacent
- _compare_memory_models — exception unwinding interacts with RAII / drop / finalizers.
- _compare_concurrency — structured concurrency is fundamentally about scoped error propagation.
- _compare_type_systems — Maybe/Optional + Result are type-system features as much as error-handling.
- _compare_metaprogramming — Lisp condition system relies on macros; effect handlers rely on continuation capture.
- _compare_build_tools —
errcheck,clippy,mypy,eslint,RuboCop— linters enforce error-handling discipline.
When to pick what
What's the application?
├─ Library / API for external consumers
│ ├─ Statically-typed lang → typed Result/Either (Rust, OCaml, Haskell, F#) or typed throws (Swift 6+)
│ └─ Dynamically-typed lang → exceptions with rich exception classes (Python, Ruby)
├─ Application internal code
│ ├─ Single thread of control → exceptions are fine; just catch at boundaries
│ └─ Concurrent → structured concurrency + typed errors propagated through task group
├─ Concurrent / distributed service with millions of connections
│ └─ Actor + supervisor (Erlang/Elixir OTP, Akka, Pony, Orleans) — let-it-crash + restart
├─ Embedded / safety-critical
│ ├─ No allocation, no unwind → C with sentinel + assert + watchdog; or Rust with panic=abort + Result + #![no_std]
│ ├─ Formal verified → SPARK Ada, Coq, Agda, Lean
│ └─ Hard real-time → C with errno; or Rust with no panic in core
├─ Web frontend (browser)
│ └─ try/catch at outer boundaries + Error Boundary (React) + global unhandled-rejection handler; Effect-TS for state machines
├─ CLI tool / script
│ ├─ Short-lived → exceptions fine; let the runtime print stack
│ ├─ Bash → `set -euo pipefail` + trap ERR
│ └─ PowerShell → -ErrorAction Stop + try/catch
├─ Long-running daemon
│ ├─ Erlang/Elixir → OTP supervisor (best)
│ ├─ Rust/Go → top-level error log + restart by systemd/k8s; Result/error propagation in code
│ └─ JVM → top-level uncaught handler + crash + k8s liveness probe
├─ Database / data pipeline
│ ├─ PL/pgSQL → EXCEPTION blocks with SQLSTATE pattern
│ ├─ Python ETL → exceptions with retries + dead-letter queue + structured logging
│ └─ Spark/Flink → checkpointing + idempotent transformations
├─ Functional core / type-driven code
│ ├─ Haskell → `Either e a`, `ExceptT`, mtl or effects (polysemy/eff)
│ ├─ Scala → ZIO / Cats Effect
│ ├─ F# → Result + computation expression
│ ├─ TypeScript → Effect-TS
│ └─ OCaml 5 → effects + `result`
└─ Scientific / numerical code
├─ MATLAB → MException + try/catch ME
├─ Julia → exceptions are cheap
├─ Python (NumPy) → catch ArithmeticError, ValueError; numerically-stable code where possible
└─ R → tryCatch + withCallingHandlers + conditions
The biggest practical lesson 2015–2026: stop arguing about exception vs Result and pick the model that matches your concurrency story. Single-thread + few errors → exceptions. Concurrent + tree-of-tasks + need-to-cancel-siblings → structured concurrency with typed errors. Distributed + fault-tolerant → supervisor restart. Library boundary → typed Result regardless of internal style. The wrong model for the wrong layer is what bakes pain into the codebase; the right model is the one that lets the next maintainer reason about every failure path in five minutes.