Skip to main content

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 its param. A param would have to be supplied on the way in, where no exception exists yet. The catch clause'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 as binding. WebAssembly has four: catch/catch_all without an as, catch_ref/catch_all_ref with (only the _ref forms push the exception reference), and the _all half when no tag is named. Reed's two source forms cover all four, which is why one keyword suffices.

See also