Chapter 20

Exceptions

A language with exceptions needs a way to unwind the stack to a handler, and WebAssembly's hidden call stack (Chapter 5) means a compiler cannot do it by itself. Before exception handling was added, compilers resorted to calling out to JavaScript to throw and catch, or to checking a flag after every call, both of which are slow. The exception handling feature, in WebAssembly 3.0, adds the instructions to do it properly. This chapter covers them, how JavaScript sees them, and how to compile try, catch and finally with them.

#Tags and throw20.1

An exception is identified by a tag, declared at the module level with the types of the values the exception carries:

(tag $not_found (param i32))           ;; carries one i32
(tag $parse_error (param i32 i32))     ;; carries two

throw pops the tag's values and throws an exception with them. Control leaves the current function, and every function between it and the nearest handler, at once.

#try_table and catch20.2

A handler is a try_table block with a list of catch clauses. Each clause names a tag and a label to branch to when an exception with that tag is thrown inside the block. The exception's values are passed to the label as branch operands, so the label's block must have results of the right types:

(module
  (tag $not_found (param i32))
  (func $find (param $key i32) (result i32)
    (if (i32.gt_u (local.get $key) (i32.const 5))
      (then (throw $not_found (local.get $key))))
    (i32.mul (local.get $key) (i32.const 100)))
  (func (export "lookup") (param $key i32) (result i32)
    (block $handler (result i32)              ;; receives the exception's i32
      (try_table (result i32) (catch $not_found $handler)
        (call $find (local.get $key)))
      (return))                               ;; normal path: return the value
    ;; exceptional path: the key is on the stack
    (i32.mul (i32.const -1))))
called as lookup(3) and lookup(9)
300
-9

If $find returns normally, the try_table ends with its result on the stack and the function returns it. If $find throws $not_found, the catch clause branches to $handler, carrying the thrown key, and the code after the $handler block negates it (multiplying by −1, since the key is already on the stack and i32.sub would need it on top).

The four kinds of catch clause are:

Clause Catches Passes to the label
catch $tag $l exceptions with $tag the exception's values
catch_ref $tag $l exceptions with $tag the values, then an exnref
catch_all $l every exception nothing
catch_all_ref $l every exception an exnref

Clauses are tried in order. An exception that matches none of them continues outward to the next handler.

#exnref and rethrowing20.3

An exnref is a reference to a caught exception. Its main use is throw_ref, which throws it again, unchanged. That is how a handler that does some work and then lets the exception continue, such as a finally block or a destructor, is compiled:

(module
  (import "env" "print_i32" (func $print (param i32)))
  (tag $error (param i32))
  (func $work (param $fail i32)
    (call $print (i32.const 1))
    (if (local.get $fail) (then (throw $error (i32.const 42))))
    (call $print (i32.const 2)))

  ;; try { work(fail) } finally { print(3) }
  (func $with_finally (param $fail i32)
    (block $on_exception (result exnref)
      (try_table (catch_all_ref $on_exception)
        (call $work (local.get $fail)))
      (call $print (i32.const 3))              ;; finally, on the normal path
      (return))
    (call $print (i32.const 3))                ;; finally, on the exceptional path
    (throw_ref))                               ;; and carry on unwinding

  (func (export "run") (param $fail i32)
    (block $caught (result i32)
      (try_table (catch $error $caught)
        (call $with_finally (local.get $fail)))
      (return))
    (call $print)))                            ;; print the error's value
called as run(0) and run(1)
1
2
3
1
3
42

The finally code appears twice, once on each path. That duplication is normal: a compiler emits the cleanup code once per way out of the protected region, or moves it into a function it calls from each.

#Exceptions and JavaScript20.4

Exceptions cross the boundary in both directions.

A WebAssembly exception that reaches JavaScript is a WebAssembly.Exception object. If the module exports the tag, JavaScript can check an exception's tag and read its values:

(module
  (tag $overflow (export "overflow") (param i32 i32))
  (func (export "add_checked") (param $a i32) (param $b i32) (result i32)
    (local $sum i64)
    (local.set $sum (i64.add (i64.extend_i32_s (local.get $a)) (i64.extend_i32_s (local.get $b))))
    (if (i64.ne (local.get $sum) (i64.extend_i32_s (i32.wrap_i64 (local.get $sum))))
      (then (throw $overflow (local.get $a) (local.get $b))))
    (i32.wrap_i64 (local.get $sum))))
import fs from 'node:fs';

const { instance } = await WebAssembly.instantiate(fs.readFileSync('main.wasm'));
const { add_checked, overflow } = instance.exports;
console.log(add_checked(1, 2));
try {
  add_checked(2 ** 31 - 1, 1);
} catch (e) {
  console.log(e instanceof WebAssembly.Exception, e.is(overflow));
  console.log('overflow adding', e.getArg(overflow, 0), 'and', e.getArg(overflow, 1));
}

3
true true
overflow adding 2147483647 and 1

JavaScript can also create tags with new WebAssembly.Tag({ parameters: ['i32'] }), pass them to modules as imports, and throw WebAssembly exceptions itself with new WebAssembly.Exception(tag, [values]).

A JavaScript exception thrown into WebAssembly, from an imported function, can be caught by catch_all or catch_all_ref, since it has no tag the module knows. The module cannot look inside it, but it can run cleanup code and rethrow it with throw_ref, or swallow it:

(module
  (import "env" "risky" (func $risky (param i32) (result i32)))
  (func (export "safe") (param $x i32) (result i32)
    (block $failed
      (try_table (result i32) (catch_all $failed)
        (call $risky (local.get $x)))
      (return))
    (i32.const -1)))
import fs from 'node:fs';

const env = { risky: (x) => { if (x < 0) throw new Error('negative!'); return x * 2; } };
const { instance } = await WebAssembly.instantiate(fs.readFileSync('main.wasm'), { env });
console.log(instance.exports.safe(21), instance.exports.safe(-5));

42 -1

The JavaScript API also provides WebAssembly.JSTag, a tag whose single externref value is the thrown JavaScript value, so a module that imports it can catch JavaScript exceptions with catch and inspect what was thrown.

#Compiling try and catch20.5

A source-level try/catch maps onto try_table directly: one tag per exception type the language distinguishes, or one tag for all exceptions carrying a pointer to an exception object, which is simpler and what C++ compilers do. Matching by the source type, for languages where a catch clause names a class and must also catch subclasses, then happens in the handler: catch the one tag, test the object's type, and throw_ref if it does not match.

Three details need care:

#The legacy instructions20.6

Before the current design, browsers shipped an earlier version of the proposal, with try, catch, catch_all, rethrow and delegate instructions, and no exnref. Toolchains emitted it for several years, so you will find it in existing modules, and engines still accept it. It proved hard to optimise and to fit with other features, and was replaced by try_table and exnref in 2024. New compilers should emit only the new instructions.

#What you have seen20.7

Tags declare exception types. throw raises an exception, and try_table catches it with clauses that branch to labels, carrying the exception's values and optionally an exnref. throw_ref rethrows, which is how finally and destructors are compiled. Exceptions cross to and from JavaScript, as WebAssembly.Exception objects one way and as catch-all exceptions the other. Entering a handler is free; throwing is not.

The next chapter covers tail calls.