Errors — throws, try & catch
xcc’s error model is checked propagation, without stack unwinding. A function that can fail says so on its signature. throw runs the scope teardown and returns. A call to such a function tests an error channel and either branches to a catch or propagates. The cost is a test-and-branch per throwing call, paid only where failure is possible.
This model is portable. Table-driven unwinding needs per-backend CFI tables, personality routines and an unwinder, which a banked 6502 with a 4 KB hardware stack cannot carry. Checked propagation uses only what the IR already has (calls, compares, branches, returns), so every backend supports it, xt6502 included.
The shape of it
Section titled “The shape of it”#import <Error.xc>
class ParseError <Error>{ String* msg; void init(String* m) { msg = m; } String* message(void) { return msg; }}
i32 parseDigit(u8 ch) throws{ if (ch < '0' || ch > '9') throw new ParseError(String.withCString("not a digit")); return (i32)(ch - '0');}
void main(void){ try { i32 n = parseDigit('7'); Stdio.printf("n=%d\n", n); } catch (ParseError e) { Stdio.printf("parse: %s\n", e.message().cString()); } catch (e) { Stdio.printf("other error\n"); }}The Error protocol
Section titled “The Error protocol”An error is an ordinary heap object. It conforms to Error, which has one requirement:
protocol Error{ String* message(void);}With that requirement, a handler can always describe what it caught without knowing the concrete type. Errors are classes, so they use the existing protocols, ARC and RTTI downcast machinery.
Error.xc lives in support/generic/lib/. It is not part of the Foundation.xc umbrella, so import it explicitly.
Errors follow ARC like any other object. A caught error is a strong local, released at the end of its catch scope.
throws — the effect on the signature
Section titled “throws — the effect on the signature”throws goes between the parameter list and the body, after any function annotations:
i32 parse(String* s) throws { … }void reload(void) :main throws { … }A throws function has one hidden trailing out-parameter. throw stores the error through it and returns, and the caller tests it after the call. The return value on the throwing path is indeterminate; the generated check ensures you never read it.
Calling a throws function is checked
Section titled “Calling a throws function is checked”A call to a throws function must be either inside a try or inside a function that is itself throws. Anything else is a sema error that names the callee and both fixes:
'readCount' is declared 'throws' — wrap the call in try { } catch (e) { },or declare the caller 'throws' to propagateThis keeps the test-and-branch off every non-throwing call, and the compiler shows you where failure can reach.
Propagation needs no syntax. A throws function that calls another throws function without a try forwards the error to its own caller:
i32 middle(u32 n) throws { i32 v = inner(n); return v + (i32)1; }throws works the same on class methods (instance and static) and on free
functions. Declaring, throwing, catching and the checked effect behave
identically for all of them.
u16 a = p.parse(c); // throws method, unguarded → rejectedu16 b = parseFree(c); // throws function, unguarded → rejectedthrow new IOError(String.withCString("cannot open"));throw evaluates its operand, stores it into the error channel, then runs every enclosing scope’s defer bodies and ARC teardown on the way out, as an early return does. A propagating throw is an ordinary exit path, so scope cleanup has no special case.
The operand should conform to Error. The compiler does not enforce this and accepts any class pointer, so a handler that calls message() on an object without that method fails at the handler, not at the throw. Conform to Error anyway, because every untyped catch is written against it.
try / catch
Section titled “try / catch”try { … }catch (IOError e) { … } // typed armcatch (ParseError e) { … } // another typed armcatch (e) { … } // untyped catch-allA try block must be followed by at least one catch. Arms are tested in source order:
- A typed arm runs only when the in-flight error is an instance of that class. The test is the same RTTI conformance check that
(T* ?)objuses, so it also works across a.soboundary. Inside the arm, the binder is already narrowed to that class, ande.message()resolves directly against it. - An untyped arm catches everything. Its binder is
Object*, the most general reference, so reaching a specific class’s members needs a cast. - If no arm matches, the error keeps propagating, out to an enclosing
tryor out of the function if it isthrows.
The class name in a typed arm has no pointer sigil: write catch (IOError e), not catch (IOError* e).
Unreachable arms are a warning
Section titled “Unreachable arms are a warning”An arm shadowed by an earlier, broader one can never run, and the compiler warns:
this 'catch' can never run — an earlier arm already catches everythingThis is a warning, not an error. Silence it with -Wno-unreachable-catch if you want to keep the dead arm (for example, a signature you intend to fill in).
A worked example
Section titled “A worked example”Using the ParseError class from the top of the page:
i32 inner(u32 n) throws{ defer { Stdio.printf(" inner-defer\n"); } if (n == (u32)1) throw new ParseError(String.withCString("deep")); return (i32)7;}
// No try here: the error propagates out to our caller.i32 middle(u32 n) throws { i32 v = inner(n); return v + (i32)1; }
void main(void){ try { i32 a = middle((u32)0); Stdio.printf("ok a=%d\n", a); } catch (e) { Stdio.printf("UNEXPECTED\n"); }
try { i32 b = middle((u32)1); Stdio.printf("UNEXPECTED b=%d\n", b); } catch (e) { Stdio.printf("caught: %s\n", ((ParseError*)e).message().cString()); }} inner-deferok a=8 inner-defercaught: deepThe defer fires on both paths, once on the ordinary return and once on the throw. middle forwards the error with no handling code.
What there is no such thing as
Section titled “What there is no such thing as”- No
finally.deferreplaces it, and attaches cleanup to the thing being cleaned up instead of to a block at the bottom of the function. - No throwing from a
deferbody. A defer running during propagation would have nowhere to send a second error, so this is forbidden. - No unchecked errors. You cannot call a
throwsfunction and ignore the possibility of failure. The cost isthrowsannotations up the call chain; in return the compiler knows where failure flows.
What’s next
Section titled “What’s next”- Statements & control flow →
defer: the cleanup mechanism errors rely on. - Inheritance & protocols: protocols and the failable downcast that typed arms use.
- Heap, ARC & weak refs: how a caught error’s lifetime is managed.
Worked example
Section titled “Worked example”throws as a checked effect, defer on the unwind path, and typed catch arms:
// errors.xc — throws / try / catch / defer, worked end to end.//// A function that can fail says so with `throws`. Callers must either handle// it in a `try` block or be declared `throws` themselves, so a failure path// cannot be ignored by accident.#import "Foundation.xc"#import "Error.xc" // not part of the Foundation umbrella#import "Stdio.xc"
// Anything thrown must conform to `Error`, which requires message().class ParseError <Error>{ String* _what; void init(void) { _what = 0; } static ParseError* with(String* what) { ParseError* e = new ParseError(); e._what = what; return e; } String* message(void) { return _what; }}
// `throws` is part of the signature: the caller can see it can fail.u16 parseDigit(u8 c) throws{ if (c < (u8)'0' || c > (u8)'9') throw ParseError.with(String.withCString("not a digit")); return (u16)(c - (u8)'0');}
// A `defer` block runs when the enclosing scope exits — on the normal path// AND when an error unwinds through it. That is what makes it useful for// releasing things.u16 sumDigits(string s) throws{ defer { Stdio.print(" (defer: sumDigits scope exited)\n"); } u16 total = (u16)0; for (u16 i = (u16)0; s[i] != (u8)0; i = i + (u16)1) total = total + parseDigit(s[i]); // may throw; propagates return total;}
i32 main(void){ // 1. The happy path. `try` guards the block; `catch (T e)` binds the // error as a T. Name the class: an untyped `catch (e)` binds `e` as // Object*, which has no message() — so it is only useful for "handle // anything and carry on", not for inspecting what went wrong. try { u16 n = sumDigits("12345"); Stdio.printf("sum of 12345 = %d\n", n); } catch (ParseError e) { Stdio.printf("unexpected: %s\n", e.message().cString()); }
// 2. The failing path — the throw unwinds out of the loop, out of // sumDigits (running its defer), and lands in catch. try { u16 n = sumDigits("12x45"); Stdio.printf("sum of 12x45 = %d\n", n); } catch (ParseError e) { Stdio.printf("caught: %s\n", e.message().cString()); }
// 3. The same, catching a throw that happens directly in the block. try { u16 d = parseDigit((u8)'!'); Stdio.printf("digit %d\n", d); } catch (ParseError e) { Stdio.printf("caught a ParseError: %s\n", e.message().cString()); } return 0;} (defer: sumDigits scope exited)sum of 12345 = 15 (defer: sumDigits scope exited)caught: not a digitcaught a ParseError: not a digitOn the happy path, sumDigits’s defer runs before the printf in the try block, because the defer fires when sumDigits returns. It also runs on the failing path, as the throw unwinds through it.