Axle v0.14.1

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 writeMeaning
fn f() : R ! Ef 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 read and be surprised by an IOException that 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-in a[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 — no catch can intercept them and you cannot list them in a ! set. The three bug-like classes only enter the ! contract when something explicitly throws them: IndexOutOfBoundsException is raised by stdlib methods that declare it (e.g. ArrayList.set / ArrayList.removeAt, argAt, substring), not by a[i]; NullPointerException and ArithmeticException are declared classes the language never raises on its own — only your code or a stdlib method (e.g. i32.absChecked()) that throws them produces them. For a bug you never want to surface as a recoverable failure, abort the process deliberately with panicMsg.

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 return inside the try computes its value before the finally runs — the finally cannot change the returned value.
  • Nested finally blocks fire innermost-first (LIFO) when one exit leaves several constructs at once.
  • When no catch matches, the finally still runs on the way out, and a finally that completes normally preserves the in-flight exception (message included) for the next handler up.
  • finally is per-construct (it fires when its try exits); defer is per-function (it fires at function exit). On a shared exit path the finally blocks 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:

  1. output.close() (LIFO — last registered, first to run)
  2. 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…UseShapeWho decides the outcome
“this value may legitimately be absent” (lookup miss, optional field)a nullable: T \| nullthe caller tests != null / ??
“this operation may fail in a way the caller can recover from” (I/O, parse, timeout)an error set: T ! Ethe caller propagates or catches
“this is an unrecoverable bug; stop now”a deliberate abortpanicMsg(msg)nobody — the process exits
an index / divide-by-zero fault(you don’t choose)implicit abortthe 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

SituationWhy
Unrecoverable I/O failureThe 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 inputDiscrete failure mode the caller will handle.
Lock contention timeoutTimeoutException is the right signal.
External system downIOException 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

LimitWhy it holds
There is no exception chainingan 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 catchablethey abort the process with a diagnostic rather than entering the ! contract
panicMsg cannot stand in a value positionit is typed : void, and the language has no diverging / Never type
A catch e : A \| B clause binds e to the nearest common baseonly the members shared across the caught types are reachable on it
A plain class may not extend Exceptiona throwable type is declared with the exception keyword (E0001)
defer is per-function, not per-blocka 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

errorsexceptionschecked-exceptions