Error handling
Axle’s failure model lives in the return type. A function that can
fail lists its error set after the return type with ! — : R ! E1, E2 reads “returns R, or fails with E1 / E2”. The compiler then
guarantees two things at every call site: the failure is visible (it
is part of the type) and it is never silent (the caller must declare
it onward or handle it).
Three spellings cover the whole surface:
| You write | Meaning |
|---|---|
fn f() : R ! E | f returns R, or fails with E (the error set) |
expr? | propagate expr’s failure to the enclosing ! set |
expr catch e { … } / try { } catch e : E { } | handle a failure (inline, or as a block) |
Think of it as the two failure shapes split across two spellings: a value
that may be absent is a nullable T | null (see Nullable types); a value that may
fail is the error set T ! E. There is no Result<T, E> / Option<T> type to construct — ! is a surface spelling over the exception
machinery, not a separate value type.
What this page covers: how a failure travels · why it lives in the return
type · the exception hierarchy and how to declare your own · throw,
propagation with ?, catch, try and finally · defer · failing
constructors · choosing between a nullable, an error set and an abort · when
to reach for exceptions and when not to · the pitfalls, and what the model
cannot do.
Failure, from first principles
Start from the problem every language has to solve: a function is asked to
do something and can’t. read is handed a path that doesn’t exist; parse is handed bytes that aren’t valid. It has a result type — a string, a Config — but on the failing run it has no such value to
return. What does it hand back, and how does the caller find out?
Three classic answers, each with the same flaw:
(1) sentinel value read() : string
return "" on failure ────► caller must remember to test for ""
— nothing forces the check
(2) error code + out-param read(path, &out) : i32
0 = ok, non-0 = which ────► caller must remember to test the code
failure — nothing forces the check
(3) out-of-band exception read() : string (may throw, unlisted)
unwinds past the caller ────► caller need not know it can throw
— the type says nothing In all three, the failure can be dropped on the floor by accident. (1) and (2) return an ordinary value the caller is free to ignore; (3) hides the failure from the type entirely, so the caller doesn’t even know to handle it. This is the single defect Axle’s model is built to remove.
Axle’s answer folds the failure into the return type and then makes the compiler refuse to let you ignore it:
fn read(path : string) : string ! IOException
▲▲▲▲▲▲ ▲▲▲▲▲▲▲▲▲▲▲
result failure set
on the part of the SAME type —
ok path you cannot call read
without seeing this Two properties follow, and they are the whole point of the model:
- Visible. The failure is written in the type. You can never call
readand be surprised by anIOExceptionthat wasn’t declared. - Never silent. The compiler forces every listed failure to be either
propagated (re-declared in your own
!set, or marked with?) or caught. There is no path — not even an accidental one — where a declared failure vanishes.
How a failure travels
When something fails, the failure climbs the call chain frame by frame
until a try/catch matches it. Every function on the way had to either
list it in its own ! set or catch it — so the climb is fully accounted
for at compile time, never a surprise at runtime:
main ──calls──► load ──calls──► read ─── throw IOException
│ │ │
│ │ │ read has no local catch, so the
│ │ ▼ IOException becomes pending and…
│ │ …rides up because load's `! IOException`
│ ▼ set declared it could…
│ load re-declares it (or marks the call `?`) — the
│ failure keeps climbing…
▼
main wraps the call in try { … } catch e : IOException { … }
── the climb stops here; the handler runs. If any frame on that path had not accounted for the IOException, the
program would not have compiled. The failure never escapes unnoticed —
that is what “total non-silence” means below.
Why failure lives in the return type
Java-style languages put the failure set in a separate throws clause
tacked onto the signature. Axle folds it into the return type with !,
for one reason: a signature already tells you what a call produces, so it
should tell you how it can fail in the same place. : Config ! IOException reads as one thing — “a Config, or an IOException” — the way a nullable
return reads as “a Config, or nothing”. There is no separate throws keyword to learn: ! subsumes it.
Two guarantees fall out of this, and they are the whole point of the model:
- Visible. A function that can fail must say so in its type. You can never call something and be surprised by a failure that wasn’t written down.
- Never silent (total non-silence). Every failure a function can
produce — whether it
throws directly or calls something that fails — must be accounted for at compile time: listed in the caller’s own!set, or caught. There is no way to drop a failure on the floor by accident. Unlike Java, this holds for every exception family: there is no “unchecked” class the compiler lets you ignore in your own code (see the hierarchy below).
The terms checked and unchecked come from Java: a checked exception must appear in signatures; an unchecked one (Java’s RuntimeException) may propagate invisibly. Axle keeps the
vocabulary but not the loophole — in your code, both are attested.
must it be listed in ! ? enforced by
┌─────────────────┬──────────────────────────┬──────────────────────┐
│ YOUR code │ every family — checked │ the compiler: │
│ │ AND unchecked (Runtime- │ omit one and it │
│ │ Exception too) │ won't compile (E0005)│
├─────────────────┼──────────────────────────┼──────────────────────┤
│ STDLIB code │ the same: every family │ the same compiler, │
│ │ │ the same rule │
└─────────────────┴──────────────────────────┴──────────────────────┘ A standard-library method’s ! set is its complete contract. If it is
not listed there, the method does not raise it — not “does not usually”, not
“only on a programmer error”. The library is compiled under the rule your own
modules are, so there is no family that can reach you unlisted.
The exception hierarchy
Everything thrown derives from Exception, the root of the hierarchy. Its
sole field is message, and it is the only declaration in the language that
derives nothing — every other exception derives Exception, implicitly when
it names no base.
Exception
├── RuntimeException (bug-like)
│ ├── IllegalArgumentException
│ ├── IllegalStateException
│ ├── NullPointerException
│ ├── IndexOutOfBoundsException
│ ├── ArithmeticException
│ ├── ClassCastException
│ ├── UnsupportedOperationException
│ ├── NoSuchElementException
│ └── ConcurrentModificationException
├── IOException (expected failure)
│ ├── FileNotFoundException
│ ├── EOFException
│ ├── SocketException
│ ├── ConnectException
│ └── HttpException
├── InterruptedException
├── TimeoutException
└── ParseException Because Exception is the root, catch e : Exception is the catch-all, and
it is also what a multi-catch catch e : A | B binds to.
The RuntimeException family is conceptually bugs — the kind of thing
you’d treat as a hard fault. But Axle draws no checked/unchecked line in user code: if you explicitly throw one, or call something that lists
one in its ! set, you must declare it onward or catch it, exactly like any
other family. There is no unchecked exemption.
Implicit faults abort the process — they are not exceptions. A division by zero (
/or%on a runtime-zero divisor) and an out-of-bounds access through the built-ina[i]operator do not raise a catchable exception. They print a diagnostic to stderr (e.g.axle: index 7 out of bounds for length 4) and terminate the process with a non-zero exit code — nocatchcan intercept them and you cannot list them in a!set. The three bug-like classes only enter the!contract when something explicitlythrows them:IndexOutOfBoundsExceptionis raised by stdlib methods that declare it (e.g.ArrayList.set/ArrayList.removeAt,argAt,substring), not bya[i];NullPointerExceptionandArithmeticExceptionare declared classes the language never raises on its own — only your code or a stdlib method (e.g.i32.absChecked()) thatthrows them produces them. For a bug you never want to surface as a recoverable failure, abort the process deliberately withpanicMsg.
All exception classes live in std/lang and are auto-imported — no use required.
Declaring an exception
Declare your own with the exception keyword. The body uses the same
syntax as a class — fields, a constructor, and methods — and it
implicitly derives Exception, so it is throwable and catchable like any
built-in:
exception NotFound {
code: i32;
path: string;
constructor(message: string, code: i32, path: string) {
super(message); // forward to Exception (carries `message`)
self.code = code;
self.path = path;
}
} Add a : supertype to subclass another exception. A derived constructor calls
the base constructor with a super(...) statement — the first statement
of the body — then sets its own fields:
exception AppError {
constructor(message: string) {
super(message); // forward to Exception (carries `message`)
}
}
exception ConfigError : AppError {
line: i32;
constructor(line: i32, message: string) {
super(message); // forward to AppError's constructor
self.line = line; // then set our own field
}
fn isFatal(self) : bool {
return self.line > 100;
}
} A plain class may not extend Exception (or any subclass) — sema
rejects it (E0001) and points at the exception form.
An exception declares a body and nothing around it: it takes no type
parameters (E0001), implements no trait (E0609 — its runtime type tag sits
where trait dispatch does not look), and carries no @derive, which would
synthesise the methods of a trait it cannot implement.
The running example
The snippets from here on share one small cast of characters. Compile this first, then any snippet below drops in beside it:
class Config {
name : string;
constructor(name : string) { self.name = name; }
pub static fn defaults() : Config { return new Config("default"); }
}
fn defaultConfig() : Config { return Config::defaults(); }
fn readFile(path : string) : string ! IOException {
if (path.isEmpty()) { throw FileNotFoundException("no path given"); }
return "name=" + path;
}
fn log(msg : string) : void { eprintln(msg); }
fn logWarning(msg : string) : void { eprintln("warning: " + msg); } ConfigParseException and parseConfig join the cast under failing constructors; divide is declared in the
next section.
Throwing
In throw position, a call on an exception type constructs and throws it in one step — there is no new (throw new X() is rejected, E0001):
fn divide(a : i32, b : i32) : i32 ! ArithmeticException {
if (b == 0) {
throw ArithmeticException("division by zero");
}
return a / b;
} throw e also rethrows an existing exception value. The error set is required: a body that throws a type (or calls something that does)
must list it in the enclosing ! set — or catch it — otherwise E0005.
Propagating
Once an error is in your ! set, a call that fails with it propagates implicitly — no per-site marker is needed:
fn safeDivide(a : i32, b : i32) : i32 ! ArithmeticException {
return divide(a, b); // failure rides up; `!` already lists it
} You can also mark a propagation site explicitly with a postfix ? — expr? returns early with the same failure if expr fails. It reads at
the call site and is the idiom when you want every fallible step visible:
fn load(p : string) : Config ! IOException, ParseException {
let raw : string = readFile(p)?; // propagate IOException
return parseConfig(raw)?; // propagate ParseException
} ? binds as propagation only where it cannot be the ternary ? … : —
i.e. when it is immediately followed by ;, ), ], }, ,, ., ?., or end of input. To feed a propagated value straight into an
operator, parenthesise it: (read(p)?) + 1.
Handling inline — catch as an expression
expr catch e { … } handles a single fallible expression in value
position. On success it yields expr’s value; on failure it binds the
exception and runs the handler, which must either supply a value of the
expected type — as its final statement, written value; with the
trailing semicolon like any statement — or exit (return / throw). catch _ { … } discards the binding:
fn loadOrDefault(p : string) : Config { // no `!` — it handles everything
let raw : string = readFile(p) catch e {
log("read failed: " + e.message);
return defaultConfig(); // exit…
};
return parseConfig(raw) catch _ {
defaultConfig(); // …or supply a fallback value
};
} It desugars to a block wrapping a try/catch, so it reuses the
exception machinery unchanged.
Catching — the try block
For multiple statements, the try / catch / finally block is the
canonical form:
fn safe(a : i32, b : i32) : i32 {
try {
return divide(a, b);
} catch e : ArithmeticException {
return 0; // sentinel for "could not"
}
} The binding is name-first — catch e : Type, mirroring let x : T; the
C-style catch (Type e) is not accepted. A try block that catches
every failure of the calls inside it turns the enclosing function into
one with no ! set — safe cannot fail.
catch e : Base matches Base and every transitive subclass — so catch e : Exception catches everything, and catch e : IOException catches FileNotFoundException / EOFException.
Multiple catch clauses
Order matters — catch the most specific subtype first:
fn loadAndParse(path : string) : Config {
try {
let text : string = readFile(path);
return parseConfig(text);
} catch e : FileNotFoundException {
return Config::defaults();
} catch e : IOException { // catches every IOException not yet caught
logWarning("io error: " + e.message);
return Config::defaults();
} catch e : ParseException {
logWarning("parse error: " + e.message);
return Config::defaults();
}
} Putting IOException before FileNotFoundException would shadow the
more specific clause — the compiler accepts it but it’s useless code.
Multi-catch
When several types share one handler, list them with | in a single
clause:
fn load() : Config {
try {
risky();
} catch e : FileNotFoundException | ParseException {
return Config::defaults(); // one body, two caught types
}
return Config::defaults();
} The bound variable e is typed as the nearest common base
(Exception), so only members shared across the caught types are
accessible on it. Every member of the union must derive from Exception (E0001).
finally
A finally block runs on every way out of the try — normal
completion, a caught exception, an uncaught one on its way to an outer
handler, and an early return / break / continue from inside the try or a catch. Use it for cleanup that must happen no matter how the
block exits:
fn process(path : string) : i32 {
let handle : i32 = openResource(path);
try {
return doWork(handle); // the finally still runs first
} catch e : IOException {
return -1;
} finally {
closeResource(handle); // always runs
}
} A try may carry a finally with no catch (try { … } finally { … }).
A throw inside the finally itself is not caught by the same try’s catch clauses — it sits outside the protected region and must
be listed in the enclosing ! set or handled further out. If the finally exits abruptly (its own return / throw), that supersedes
whatever the try / catch was doing.
Guarantees you can rely on:
- A
returninside thetrycomputes its value before thefinallyruns — thefinallycannot change the returned value. - Nested
finallyblocks fire innermost-first (LIFO) when one exit leaves several constructs at once. - When no
catchmatches, thefinallystill runs on the way out, and afinallythat completes normally preserves the in-flight exception (message included) for the next handler up. finallyis per-construct (it fires when itstryexits);deferis per-function (it fires at function exit). On a shared exit path thefinallyblocks run first, then the function’s deferred statements.
Cleanup with defer
defer is the idiomatic way to release resources on every exit path
(normal return, throw, fall-through). It runs at function exit, in
LIFO order:
use std::io::FileInputStream;
use std::io::FileOutputStream;
use std::io::FileOpenMode;
fn copyFile(src : string, dst : string)
: void ! IOException, FileNotFoundException
{
let input : FileInputStream = new FileInputStream(src);
defer input.close(); // closes even on throw
let output : FileOutputStream = new FileOutputStream(dst, FileOpenMode::WRITE);
defer output.close();
let b : i32 = input.read();
while (b >= 0) { output.write(b); b = input.read(); }
} If output.write(b) fails with an IOException, both defers
still fire:
output.close()(LIFO — last registered, first to run)input.close()
Only then does the failure propagate.
Scope quirk : defer in a nested block
use std::io::File;
fn loop() : void ! IOException {
let acc : i64 = 0;
for (i of 0..10) {
let f : File = new File("/tmp/" + i + ".txt");
defer f.close(); // <-- bug !
acc = acc + f.length();
}
// f.close() runs HERE, at function exit — 10 files leak !
} The defer is bound to the enclosing function, not the for body.
Each iteration registers a fresh defer that piles up until the function
returns. Fix : wrap the body in a helper:
use std::io::File;
fn loadOne(i : i32) : i64 ! IOException {
let f : File = new File("/tmp/" + i + ".txt");
defer f.close(); // closes when loadOne returns
return f.length();
}
fn loop() : void ! IOException {
let acc : i64 = 0;
for (i of 0..10) {
acc = acc + loadOne(i);
}
} Failing constructors
A constructor declares its error set the same way — constructor(...) ! E — and a new X(...) that fails propagates E to its caller like any
call. A failing new unwinds the half-built object and the frame’s owned
locals exactly once before the failure escapes:
exception ConfigParseException : ParseException {
line: i32;
constructor(line: i32, message: string) {
super(message); // `message` lives on `Exception`, the root
self.line = line;
}
}
fn parseConfig(text : string) : Config ! ConfigParseException {
if (text.isEmpty()) {
throw ConfigParseException(1, "config is empty");
}
return new Config(text);
} message is inherited from Exception, the root of the hierarchy, so it is
forwarded with super(message) (never assigned directly or re-declared); line is the exception’s own field.
Reading exception fields
try {
risky();
} catch e : Exception {
let m : string = e.message; // user-supplied text
log(m);
} Every exception carries at least message : string. Subclasses may add
their own fields (e.g. the code and path fields on the NotFound exception declared above).
Aborting on a bug
Axle has no panic keyword — exceptions (throw + !) are the whole
failure model. For an unrecoverable bug you don’t want to surface as a
declared failure, panicMsg — a low-level intrinsic in std::ffi::runtime — aborts the process with a message instead of entering the ! contract:
use std::ffi::runtime::panicMsg;
fn applyBuiltinTemplate(name : string) : void {
if (!isKnownTemplate(name)) {
panicMsg("internal: unknown built-in template"); // aborts, never returns
}
// … safe to proceed …
} A hard abort is for “this can’t happen / the program is broken” — it is
never listed in an error set and never caught. Reach for it where Rust
would reach for panic!; reach for throw + ! where Rust would reach
for Result. Note panicMsg is typed : void (Axle has no diverging / Never type), so it stands as a statement, not a value — it can’t
fill a catch _ { … } value slot.
Choosing a failure signal
Axle gives you four distinct ways a call can “not produce a value”, and picking the right one is most of writing clear code. They are not interchangeable — each says something different about what kind of non-result it is:
| You want to say… | Use | Shape | Who decides the outcome |
|---|---|---|---|
| “this value may legitimately be absent” (lookup miss, optional field) | a nullable | : T \| null | the caller tests != null / ?? |
| “this operation may fail in a way the caller can recover from” (I/O, parse, timeout) | an error set | : T ! E | the caller propagates or catches |
| “this is an unrecoverable bug; stop now” | a deliberate abort | panicMsg(msg) | nobody — the process exits |
| an index / divide-by-zero fault | (you don’t choose) | implicit abort | the runtime prints + exits |
The decision flow:
can the caller sensibly continue if this "fails"?
│
├─ it's just "no value here" → T | null (no unwind)
├─ yes, with a recovery path → T ! E (throw / catch)
└─ no, the program is broken → panicMsg (abort) Reach for T | null before T ! E whenever “failure” is really just
absence — a missing key is not an exceptional condition, and the nullable
carries no unwind cost. Reach for panicMsg only for “this can’t happen”:
it is never listed in a ! set and never caught (see Aborting on a
bug).
When not to use exceptions
Exceptions are for exceptional conditions, not happy-path control flow. The compiler doesn’t enforce this, but the runtime cost adds up.
Bad : exceptions as if
// BAD — manufacturing an exception for an expected "absent key" just to
// branch on it. `get` already returns `V | null`, so the throw only adds
// unwind cost on the happy path.
fn pick(map : HashMap<string, i32>) : i32 ! NoSuchElementException {
if (map.get("alice") == null) {
throw NoSuchElementException("alice");
}
return map.get("alice") ?? 0;
} Good : let the nullable return speak
fn pick(map : HashMap<string, i32>) : i32 {
return map.get("alice") ?? 0; // null on miss, no unwind
} HashMap.get / ArrayList.get return V | null precisely so a missing
key never needs the exception machinery — see the lookup patterns in collections.md.
When to use exceptions
| Situation | Why |
|---|---|
| Unrecoverable I/O failure | The caller probably can’t continue ; surface a typed error. |
API contract violation by caller (null where non-null required) | Crash loud — IllegalArgumentException. |
| Parser hitting malformed input | Discrete failure mode the caller will handle. |
| Lock contention timeout | TimeoutException is the right signal. |
| External system down | IOException family — caller decides retry policy. |
For “could not find / could not match”, a sentinel return is usually
cleaner — see the HashMap lookup pattern in collections.md.
Re-throwing
To wrap or annotate an exception before re-throwing:
fn loadConfig(path : string) : Config ! ConfigParseException {
try {
let text : string = readFile(path);
return parseConfig(text);
} catch e : IOException {
throw ConfigParseException(0, "io error: " + e.message);
}
} Axle has no exception chaining, so the original IOException is not reachable
from the wrapper — carry its message in the new one.
Asserting invariants
IllegalStateException is the convention for “the world isn’t in the
state I expected”:
class Pipeline {
started : bool;
constructor() {
self.started = false;
}
pub fn start(mut self) : void ! IllegalStateException {
if (self.started) {
throw IllegalStateException("pipeline already started");
}
self.started = true;
}
} IllegalArgumentException is the analog for “caller passed bad input”:
fn squareRoot(x : f64) : f64 ! IllegalArgumentException {
if (x < 0.0) {
throw IllegalArgumentException("squareRoot of negative");
}
return x.sqrt();
} Both are RuntimeException subclasses. They fire on programmer mistakes,
so prefer a hard abort (std::ffi::runtime::panicMsg) when the caller
genuinely cannot recover; declare them in ! when a caller is expected to
catch and react.
Getting a value out of a try
The inline catch expression is the direct way — it is try in value
position (see Handling inline):
fn parsedConfig(path : string) : Config {
return parseConfig(readFile(path)) catch _ {
Config::defaults();
};
} When several statements must share one set of handlers, fall back to a
mutable binding seeded with a default and a block try:
fn parsedConfig(path : string) : Config {
let result : Config = Config::defaults();
try {
result = parseConfig(readFile(path));
} catch e : IOException {
// result stays at defaults
} catch e : ParseException {
// result stays at defaults
}
return result;
} Common pitfalls
Swallowing without a comment
try { risky(); } catch e : Exception { } // silent failure If you really mean “ignore”, say so:
try { sendOptionalTelemetry(); }
catch e : IOException {
// Fire-and-forget — telemetry failure is not user-visible.
} Catching Exception for non-recovery
catch e : Exception is the catch-all root. It silences every failure —
including ones future code may add to the inner call. Catch the specific type unless you’re at a request boundary that turns any
failure into a 500 / error log.
Limitations
| Limit | Why it holds |
|---|---|
| There is no exception chaining | an exception carries a message; the failure it wrapped is not reachable from it |
A division by zero and an out-of-bounds a[i] are not catchable | they abort the process with a diagnostic rather than entering the ! contract |
panicMsg cannot stand in a value position | it is typed : void, and the language has no diverging / Never type |
A catch e : A \| B clause binds e to the nearest common base | only the members shared across the caught types are reachable on it |
A plain class may not extend Exception | a throwable type is declared with the exception keyword (E0001) |
defer is per-function, not per-block | a defer written in a loop body registers once per iteration and fires at function exit |
? binds as propagation only where it cannot be the ternary ? | feed a propagated value into an operator by parenthesising it: (read(p)?) + 1 |
See also
- Reading compiler errors — diagnostic format
- Exception dispatch — how
throw/?/catchresolve underneath std/langreference — exception class details- Conventions § Error handling — style guide
- Concept index — every exception concept cross-linked