Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

17. Exceptions for error handling

  • Status: Accepted
  • Date: 2026-09-03

Context

Through Milestone 1 every runtime fault was terminal: an out-of-range index, a type error, a int() that could not parse — each printed a diagnostic and the program stopped. There was no try, no raise, and no way for a program to respond to a failure it could reasonably expect (a missing map key it wants to default, a parse it wants to retry, a file that might not exist — the last one blocked by the absence of file I/O, which this milestone also adds).

A language that cannot recover from failure cannot be used to write a tool that keeps running. The question is which recovery model:

  • Exceptionsraise a value, try / except catches it, unwinding the stack in between. Familiar (Python, Ruby, JS), and it maps directly onto the interpreter's existing Signal enum, which already unwinds the Rust stack for return / break / continue.
  • Result values — fallible operations return an ok/err union the caller must inspect (Rust, Go-ish). No stack unwinding, errors are values on the normal path. But it needs either a ?-style operator or pervasive manual propagation, and it forces every existing built-in operation to change its return type.
  • Error callbacks / handlers — a registered handler function. Clumsy for the common "try this, fall back to that" shape.

Korrin already has dynamic typing, nil, and stack-unwinding control flow. Exceptions fit that grain; a Result type does not without more type machinery than the language has.

Decision

Korrin gets exceptions, with four new keywords (try, except, finally, raise — keyword count 19 → 23, see the amendment to ADR 0006).

raise

raise <expression> evaluates the expression and unwinds with that value. Any value may be raisedraise Error("bad port"), raise "boom", raise 404. There is no "throwable" type to conform to. Bare raise (re-raise) is not provided; re-raise is raise err from inside a handler that bound err.

try / except / finally

try:
    <body>
except [name]:          # optional
    <handler>
finally:                # optional
    <cleanup>
  • The body runs. If it raises and there is an except, the handler runs with name bound to the raised value (except: with no name catches without binding). There is exactly one except per try — it is a catch-all, not a type filter. To handle only some errors, inspect the value in the handler and raise it again otherwise.
  • finally runs on every exit path: normal completion, a caught exception, an uncaught exception passing through, and a return / break / continue leaving the try. If finally itself raises or transfers control, that outcome replaces whatever was pending.
  • At least one of except / finally must be present; a bare try: is E0107. A dangling except: / finally: with no try is the same error.

Built-in faults are catchable

The Milestone 1 runtime faults (E0301E0313) are no longer special: a try catches them exactly as it catches a raise. When caught, the fault is bound as an Error instance with .message (the diagnostic text) and .code (the "E03xx" string).

The Error type

Error is a built-in class, bound in the global scope. Error(message?) constructs an instance with .message (the argument, or "error") and .code (nil for program-created errors). It is the conventional thing to raise, and the thing a caught built-in fault is presented as, so a handler can treat both uniformly:

try:
    ...
except err:
    log("{err.code}: {err.message}")

Uncaught

An exception that reaches the top level with no try around it prints a diagnostic and exits non-zero, like a Milestone 1 fault did. An uncaught Error shows its .message; any other uncaught value shows as uncaught: <repr>. The caret points at the raise. The code is E0312.

Implementation

Signal gains a Raise { value, span } variant beside the existing Error(Diagnostic). Both are "exceptions"; try catches either, return / break / continue pass through it (but still trigger finally). No fault site in the interpreter had to change — Signal::Error is caught at the try boundary and wrapped into an Error instance there, lazily.

Consequences

  • Programs can recover. File I/O (added in the same milestone) and every conversion (int("x")) become usable without a pre-check for every case.
  • The Signal enum and every exhaustive match on it grew one arm — the compiler found them all.
  • finally's "runs on every path, its own control flow wins" rule is powerful and slightly dangerous: finally: return x silently swallows a pending exception. This matches Python; it is documented in spec §5.10 with a warning.
  • One except per try, no type filtering, keeps the feature small. The cost is that selective handling is a manual if err.code == "..." / raise err. For a language this size that is an acceptable trade; a typed except Foo: form can be added later without breaking anything.
  • Error is a real class value but with a native constructor (it sets .code, which a plain Korrin init could not distinguish from a user field). This is the first built-in class; the mechanism (instantiate special-cases it by identity) is contained and does not generalise yet.
  • Four keywords is a large single addition to a set that prides itself on being small. Each is load-bearing and none has an alias — see the ADR 0006 amendment.

Alternatives considered

  • A Result / okerr type. The other mainstream model. Rejected: it needs a propagation operator or verbose manual threading, changes the return type of every fallible built-in, and sits awkwardly in a language with no algebraic types or pattern matching. Exceptions reuse machinery Korrin already has.
  • Multiple typed except clauses (except KeyError: / except:). More familiar to Python users and more precise. Deferred: Korrin has no exception hierarchy to filter on yet, and adding clauses later is backward compatible.
  • Bare raise for re-raise. Convenient in Python. Left out because it needs the interpreter to track "the currently-handled exception" as hidden state; raise err naming the bound value is explicit and needs nothing.
  • throw instead of raise. raise pairs with except in the Python tradition Korrin's block syntax already borrows from; throw/catch is the C++/JS pairing. Either works; raise/except was chosen for consistency with the rest of the surface.
  • No finally, just except. finally is the part that is genuinely hard to get right by hand (cleanup on every path, including return). Keeping it is worth the extra keyword.

Amendment (2026-09-04): a second implementation

The semantics above are unchanged. The bytecode VM implements them a different way, inlining finally at each statically visible exit and keeping a runtime handler stack only for exceptions arriving from elsewhere (ADR 0025). Both engines are held to the behaviour described here by the differential suite (ADR 0024).