Exceptions
Reed has WebAssembly's exception handling, with checked function signatures: declare a
tag, state which tags may escape a function, throw it, catch it, explicitly propagate it,
or rethrow it. What it does not have is anything resembling an exception hierarchy, a
stack trace, or a finally. A tag is a name plus a payload signature, and that is all
there is to it.
tag NotFound(i32);
export func check as "check";
func check() -> i32 {
var code: i32 = 0;
try {
throw NotFound(404);
} catch NotFound(status) {
code = status;
}
return code;
}
Declaring and throwing
A tag declaration names an exception and the values it carries:
tag Overflow; // no payload
tag NotFound(i32); // one i32
tag Range(i32, i32); // two
throw Name(args...); raises it. The arguments must match the declared types exactly,
and a throw diverges: nothing after it in the same block is reachable. If no enclosing
try catches that tag, the function must declare it after its result type:
tag NotFound(i32);
func lookup() -> i32 throws (NotFound) {
throw NotFound(404);
}
throws is contextual, so it remains available as an ordinary identifier elsewhere.
Omitting the clause means the function cannot let any checked tag escape.
An exception declared by every function it escapes may unwind out of the module, where it
becomes the host's problem. In wasmtime that surfaces as a trap naming the tag.
Calling and propagating
A call needs no marker when an enclosing try catches every tag the callee declares:
try {
lookup();
} catch NotFound(status) {
log(status);
}
Otherwise add ? immediately after the call and declare the tags that remain unhandled:
tag Io(i32);
tag Parse(i32);
func read() -> i32 throws (Io) { throw Io(5); }
func parse() -> i32 throws (Io, Parse) {
let value: i32 = read()?;
if (value < 0) { throw Parse(value); }
return value;
}
The marker is explicit propagation, not a value operator. It does not bypass local
handlers. In a try that catches Io, parse()? propagates only Parse. A ? that has
nothing left to propagate is rejected, as is propagation of a tag absent from the current
function's throws clause. Named function types and imported functions use the same clause:
type Reader = func() -> i32 throws (Io);
import "env"."read" as func host_read() -> i32 throws (Io);
Catching
A try block takes one or more handlers. catch Name(bindings) matches one tag and binds
its payload. A catch that names no tag matches anything and binds nothing — naming no
tag is what makes a handler universal, so there is no separate keyword for it.
tag Alpha(i32);
tag Beta(i32, i32);
func inner(which: i32) -> () throws (Alpha, Beta) {
if (which == 1) { throw Alpha(10); }
if (which == 2) { throw Beta(3, 4); }
}
export func check as "check";
func check() -> i32 {
var total: i32 = 0;
for i in 0..3 {
try {
inner(i);
total = total + 100;
} catch Alpha(a) {
total = total + a;
} catch Beta(x, y) {
total = total + x * y;
} catch {
total = total + 1000;
}
}
// i=0 throws nothing (+100), i=1 raises Alpha (+10), i=2 raises Beta (+12).
return total;
}
That returns 122. The tagless catch never runs, because handlers are matched in order
and the two specific handlers come first.
Order is meaningful, and unreachable handlers are errors. Because matching is
in-order, a handler after a tagless catch could never run, and neither could a second
catch for a tag an earlier catch already names. Both are rejected rather than silently
compiled as dead code:
try { ... } catch { } catch Alpha(a) { }
type error: this handler can never run: the tagless 'catch' at line 1 already matches
every exception, so it must come last
try { ... } catch Alpha(a) { } catch Alpha(b) { }
type error: duplicate handler for tag 'Alpha'; the one at line 1 already catches it
A handler's bindings must match the tag declaration, not merely be self-consistent:
tag NotFound(i32);
try { ... } catch NotFound(a, b) { }
type error: tag 'NotFound' carries 1 value(s), but this handler binds 2
Rethrowing
Add as name to either clause to also bind the exception itself, as an &exn. The only
thing you can do with an exn is throw it again, with the lowercase form of throw:
tag Boom(i32);
func thrower(code: i32) -> () throws (Boom) { throw Boom(code); }
func middle(code: i32) -> () throws (Boom) {
try {
thrower(code);
} catch Boom(n) as e {
// Handle only the case we understand; anything else goes back up unchanged.
if (n != 7) { throw e; }
}
}
export func check as "check";
func check() -> i32 {
var seen: i32 = 0;
// 7 is handled by the inner handler and never reaches us; 9 is rethrown and does.
try {
middle(7);
seen = seen + 1;
} catch Boom(unexpected) {
seen = seen + 100 * unexpected;
}
try {
middle(9);
seen = seen + 1000;
} catch Boom(n) {
seen = seen + n;
}
return seen;
}
That returns 10: the first try completes normally (+1) because the inner handler
swallowed Boom(7), and the second one catches the rethrown Boom(9) (+9). Had the
rethrow not preserved the payload, the second total would be wrong; had it not happened at
all, seen would be 1001.
catch as e is the interesting one: it lets you observe an exception whose tag your code
does not even name, and put it back.
try {
risky();
} catch as e {
Failures = Failures + 1;
throw e; // propagates the original exception, tag and payload intact
}
throw e; and throw Tag(...); are the same keyword, told apart by case. A
PascalCase name is a tag, so throw NotFound(404); raises a new exception. A
snake_case name is a local, so throw e; rethrows the one bound by an enclosing
catch ... as e. This is the same naming
convention the resolver uses everywhere else, which is why there is no
rethrow keyword. Using it on the wrong kind of thing says so:
try { ... } catch NotFound(status) { throw status; }
type error: 'status' has type i32, but 'throw status' requires a caught exception
(&exn), which only a 'catch ... as status' clause produces
If you know WebAssembly's own spelling: WAT calls the universal handler catch_all,
and Reed lowers to exactly that. The source form drops the word because a catch with no
tag already says it, the same way throw e needs no rethrow keyword. Writing catch_all
tells you so:
try { ... } catch_all { }
syntax error: 'catch_all' is no longer a keyword; a 'catch' naming no tag already
matches every exception, so write 'catch { ... }' (or 'catch as e { ... }')
What an exn is not
&exn is its own type family — disjoint from any/eq, from func, and from extern.
So an exn cannot be stored in a struct field, compared with ==, tested with is, cast,
or passed to a function. There is no operation on it other than the rethrow above. This
mirrors WebAssembly, where an exnref is deliberately opaque.
Control flow through a try
break, continue, br, and return inside a protected body target exactly what they
would target outside it — a try is not a branch target and cannot be labelled:
tag Stop(i32);
export func check as "check";
func check() -> i32 {
var total: i32 = 0;
for i in 0..10 {
try {
if (i == 3) { throw Stop(i); }
total = total + 1;
if (i == 5) { break; } // exits the for loop, not just the try
} catch Stop(at) {
total = total + 100 * at;
}
}
return total;
}
That returns 305: three iterations add 1, i == 3 throws and adds 300, then two more
add 1 before the break.
A try produces no value
There is no expression form. A try that needs to produce something assigns to a var
declared outside it, exactly as an if statement does — which is what every example above
does with code/total/seen.
The reason is in the lowering. try_table branches out of the protected region to a
handler label, so the body and each handler produce their values on different edges of one
construct; giving the whole thing a result type would mean unifying those edges, and the
source has no syntax to state that. A var is clearer than any syntax that could.
What it compiles to
try_table, one enclosing block per handler as its branch target:
(block $try_done
;; catch NotFound
(block $try_handler_0 (result i32)
(try_table (catch $NotFound $try_handler_0)
;; throw NotFound(...)
(throw $NotFound (i32.const 404)))
;; body completed: skip the handlers
(br $try_done))
(local.set $status)
(local.set $code (local.get $status))
(br $try_done))
Three things are worth noticing, because they explain the shape:
- The handler's payload arrives as the block's
result, not itsparam. Aparamwould have to be supplied on the way in, where no exception exists yet. Thecatchclause's implicit branch delivers a result. - Each region ends with
(br $try_done). That is what makes handlers mutually exclusive; without it, control would fall out of one handler's block straight into the next handler's body — valid WebAssembly, wrong program. - The clause opcode depends on the tag and the
asbinding. WebAssembly has four:catch/catch_allwithout anas,catch_ref/catch_all_refwith (only the_refforms push the exception reference), and the_allhalf when no tag is named. Reed's two source forms cover all four, which is why one keyword suffices.
See also
- Spec section 10.1 — the normative grammar, the handler-ordering rules, and the lowering.
- Spec section 10
—
tagdeclarations and the throwing half. - Control flow — how
break/continue/brwork in general. - Diagnostics — every exception-related error message.