Error Values vs Exceptions: What Actually Happens at Runtime

Practical error handling tradeoffs with runtime internals, not slogans.

I keep seeing error handling debates framed as taste.

For me, it is a control-flow and operations problem: who sees failures, where they propagate, and how expensive the bad path gets when production is already on fire.

why this still matters in 2026

Systems have more boundaries than ever: services, queues, workers, wasm sandboxes, edge runtimes, and mobile clients all talking to each other.

If your failure model is vague, you get:

  • retries without intent
  • logs without root cause
  • catch blocks that flatten everything into “500”
  • latency tails that only show up under load

So this is not about ideology. It is about whether your failure semantics survive scale.

error values vs exceptions in one sentence

  • exceptions: failure is implicit control transfer
  • error values: failure is explicit data in the type/return contract

The difference is where the complexity lives: runtime unwind and handler lookup, or explicit branching at call sites.

what exceptions do internally

The details vary by runtime, but the throw path shape is predictable.

  1. Construct or reference an exception object.
  2. Read metadata for current frame/function.
  3. Walk stack frames to find a handler.
  4. Run cleanup during unwinding.
  5. Transfer control to handler landing pad / catch block.

c++ (itanium-style model on common platforms)

Compilers emit unwind tables and landing pads. On the non-throw path there is usually little to no dynamic overhead from exception checks. On the throw path the runtime uses unwind metadata to step frames and run destructors.

#include <stdexcept> #include <string> std::string load_or_throw(bool ok) { if (!ok) throw std::runtime_error("config missing"); return "ok"; } int main() { try { auto v = load_or_throw(false); (void)v; } catch (const std::runtime_error& e) { // handler reached only after metadata-driven unwind } }

jvm / clr

Managed runtimes maintain exception metadata and use JIT/runtime support to map throw sites to handlers and finally blocks. The mechanism is different from native ABIs, but the principle is the same: uncommon path does table lookup + unwind semantics.

fun parsePort(s: String): Int { val p = s.toInt() // NumberFormatException require(p in 1..65535) { "port out of range" } // IllegalArgumentException return p } fun main() { try { println(parsePort("70000")) } catch (e: IllegalArgumentException) { println("bad port: ${e.message}") } }

go panic/recover is not “exceptions with different spelling”

panic unwinds deferred calls in the same goroutine. recover only works inside deferred functions during panic unwinding.

func guarded() { defer func() { if r := recover(); r != nil { fmt.Println("recovered:", r) } }() panic("boom") }

That makes panic/recover useful for containment boundaries, not a primary domain error model.

where exception cost actually appears

“Zero-cost exceptions” means cheap normal path, not cheap throw path.

Cost tends to show up in:

  1. unwinding and handler search on throw-heavy paths
  2. noisy telemetry from broad catch blocks
  3. hidden failure contracts at API boundaries
  4. impedance mismatch when mapping exceptions to wire errors

Even if average latency is fine, p95/p99 often gets worse when expected failures are modeled as throws.

what value-based errors do internally

Value-based errors keep failure in the data path.

Implementation-wise, you generally have a tagged success/failure representation, explicit branch at the call site, and propagation helpers (?, try, catch, combinators).

In practice this gives better local reasoning because the call signature advertises the failure channel.

use thiserror::Error; #[derive(Debug, Error)] enum ParseError { #[error("invalid integer")] InvalidNumber, #[error("port out of range")] OutOfRange, } fn parse_port(input: &str) -> Result<u16, ParseError> { let p = input.parse::<u16>().map_err(|_| ParseError::InvalidNumber)?; if p == 0 { return Err(ParseError::OutOfRange); } Ok(p) }
const std = @import("std"); const PortError = error{ InvalidNumber, OutOfRange }; fn parsePort(input: []const u8) PortError!u16 { const p = std.fmt.parseInt(u16, input, 10) catch return PortError.InvalidNumber; if (p == 0) return PortError.OutOfRange; return p; }

go, rust, zig: three different tradeoff profiles

go: explicit, minimal, operationally friendly

Go’s model is boring in a good way.

var ErrNotFound = errors.New("not found") type ParseError struct{ Field string } func (e *ParseError) Error() string { return "parse failed: " + e.Field } func readUser(id string) (string, error) { if id == "" { return "", &ParseError{Field: "id"} } if id == "404" { return "", fmt.Errorf("repo read: %w", ErrNotFound) } return "ok", nil } func handle(id string) { _, err := readUser(id) if err == nil { return } if errors.Is(err, ErrNotFound) { fmt.Println("return 404") return } var p *ParseError if errors.As(err, &p) { fmt.Println("bad input field:", p.Field) return } fmt.Println("return 500") }

rust: explicit + composable + compile-time pressure

Rust makes it hard to accidentally ignore failures and gives structured error composition through enums and traits.

fn load_config(path: &str) -> Result<String, std::io::Error> { std::fs::read_to_string(path) } fn boot() -> Result<(), Box<dyn std::error::Error>> { let cfg = load_config("app.toml")?; println!("{}", cfg.len()); Ok(()) }

zig: explicit with lightweight surface area

Zig’s error sets and unions make failure states obvious without adding much abstraction.

fn openConfig(path: []const u8) !void { _ = path; return error.NotFound; } pub fn main() void { openConfig("app.toml") catch |err| { std.debug.print("config error: {}\n", .{err}); }; }

when exceptions are still the right tool

I still use exceptions for:

  • framework call chains that already encode failure through exceptions
  • constructor/initialization failures in OO-heavy code
  • truly exceptional states where local recovery is meaningless

The line I care about: if failure is expected and frequent, it should usually be explicit in the API.

practical strategy for production systems

I use this split:

LayerDefaultWhy
Library/domain codeError values (Result, typed error objects, unions)Callers need explicit contracts
Service/applicationMap + enrich + classifyAttach context, decide retryability
Process boundary (HTTP/RPC)Stable error envelopeAvoid leaking runtime-specific details
Invariant break/bugassert/panic/abort + telemetryFast fail beats corrupted state

Simple error envelope pattern:

{ "code": "user.not_found", "message": "user does not exist", "retryable": false, "trace_id": "01HR..." }

checklist

  1. Model expected failures as data.
  2. Reserve exceptions/panics for truly exceptional or bug states.
  3. Wrap errors with local context at each boundary.
  4. Classify errors into domain, operational, and bug classes.
  5. Measure throw-heavy paths with p95/p99, not just average latency.