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:
- Exceptions —
raisea value,try/exceptcatches it, unwinding the stack in between. Familiar (Python, Ruby, JS), and it maps directly onto the interpreter's existingSignalenum, which already unwinds the Rust stack forreturn/break/continue. - Result values — fallible operations return an
ok/errunion 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 raised — raise 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 withnamebound to the raised value (except:with no name catches without binding). There is exactly oneexceptpertry— it is a catch-all, not a type filter. To handle only some errors, inspect the value in the handler andraiseit again otherwise. finallyruns on every exit path: normal completion, a caught exception, an uncaught exception passing through, and areturn/break/continueleaving thetry. Iffinallyitself raises or transfers control, that outcome replaces whatever was pending.- At least one of
except/finallymust be present; a baretry:isE0107. A danglingexcept:/finally:with notryis the same error.
Built-in faults are catchable
The Milestone 1 runtime faults (E0301–E0313) 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
Signalenum 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 xsilently swallows a pending exception. This matches Python; it is documented in spec §5.10 with a warning.- One
exceptpertry, no type filtering, keeps the feature small. The cost is that selective handling is a manualif err.code == "..."/raise err. For a language this size that is an acceptable trade; a typedexcept Foo:form can be added later without breaking anything. Erroris a real class value but with a native constructor (it sets.code, which a plain Korrininitcould not distinguish from a user field). This is the first built-in class; the mechanism (instantiatespecial-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/ok–errtype. 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
exceptclauses (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
raisefor re-raise. Convenient in Python. Left out because it needs the interpreter to track "the currently-handled exception" as hidden state;raise errnaming the bound value is explicit and needs nothing. throwinstead ofraise.raisepairs withexceptin the Python tradition Korrin's block syntax already borrows from;throw/catchis the C++/JS pairing. Either works;raise/exceptwas chosen for consistency with the rest of the surface.- No
finally, justexcept.finallyis the part that is genuinely hard to get right by hand (cleanup on every path, includingreturn). 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).