Throwing JavaScript Exceptions from Rust

This page answers one task: a Rust function exported with wasm-bindgen can fail, and JavaScript callers should receive a real Error — with a message, a name they can test and a stack — instead of a string, a trap, or undefined.

Prerequisites

  • [ ] A Rust crate built with wasm-bindgen (wasm-pack or wasm-bindgen-cli).
  • [ ] js-sys as a dependency, for constructing JavaScript error objects.
  • [ ] The basics of returning Result, covered in propagating Rust results to JavaScript.

What wasm-bindgen does with an Err

When an exported function returns Result<T, E>, wasm-bindgen generates glue that checks the result after the call. Ok(value) becomes the return value; Err(e) is converted to a JavaScript value and thrown. So the question is not whether an error is thrown — it always is — but what is thrown. That depends entirely on E.

If E is JsValue built from a string, JavaScript catches a bare string: no message property, no stack, no instanceof Error. Error trackers group these badly and many catch blocks that read err.message print undefined. If E is a js_sys::Error, JavaScript catches a genuine Error object. And if E is your own type annotated with #[wasm_bindgen], JavaScript catches an instance of a generated class — which is not an Error at all unless you arrange it. Choosing E deliberately is the whole job.

What JavaScript catches for different Err types A string JsValue is thrown as a bare string with no stack. A js_sys::Error is thrown as a real Error with message and stack. A custom wasm-bindgen struct is thrown as a wrapper object, not an Error, unless converted into one. Err(JsValue::from_str(..)) catch receives a string no message, no stack instanceof Error is false avoid Err(js_sys::Error::new(..)) catch receives an Error message and stack present name can be set the default choice custom bindgen struct catch receives a class instance fields and methods available not an Error unless converted convert at the edge

Step 1 — return js_sys::Error instead of strings

The smallest improvement is to return real errors:

use wasm_bindgen::prelude::*;
use js_sys::Error;

#[wasm_bindgen]
pub fn parse_config(text: &str) -> Result<JsValue, Error> {
    let cfg: Config = toml::from_str(text).map_err(|e| Error::new(&format!("invalid config: {e}")))?;
    serde_wasm_bindgen::to_value(&cfg).map_err(|e| Error::new(&e.to_string()))
}

JavaScript now gets Error: invalid config: expected '=' at line 3, with err.message set and a stack that points at the call site in JavaScript. js_sys::Error converts into JsValue automatically, so it works anywhere wasm-bindgen expects an error.

Step 2 — give errors names and codes

Callers branch on kinds of failure, so set name and attach a code. js_sys::Error::set_name sets the name; Reflect::set adds properties:

fn js_error(name: &str, code: &str, message: &str) -> Error {
    let err = Error::new(message);
    err.set_name(name);
    let _ = js_sys::Reflect::set(&err, &"code".into(), &code.into());
    err
}

#[wasm_bindgen]
pub fn decode(bytes: &[u8]) -> Result<Vec<u8>, Error> {
    if bytes.len() < 8 { return Err(js_error("FormatError", "ETRUNC", "input shorter than header")); }
    inner_decode(bytes).map_err(|e| js_error("FormatError", e.code(), &e.to_string()))
}

In JavaScript, err.name === "FormatError" and err.code === "ETRUNC", and String(err) prints FormatError: input shorter than header. Testing the name is the reliable way to branch, since every such error is still an instance of the base Error class.

Step 3 — convert your error type with From

Inside the crate, errors should be ordinary Rust types — an enum with thiserror — so library code does not depend on JavaScript. Convert at the boundary with a From impl, and the ? operator does the rest:

#[derive(thiserror::Error, Debug)]
pub enum DecodeError {
    #[error("input shorter than header")] Truncated,
    #[error("unsupported format version {0}")] Version(u8),
    #[error("image exceeds {0} pixels")] TooLarge(u32),
}

impl From<DecodeError> for JsValue {
    fn from(e: DecodeError) -> JsValue {
        let (name, code) = match e {
            DecodeError::Truncated => ("FormatError", "ETRUNC"),
            DecodeError::Version(_) => ("FormatError", "EVERSION"),
            DecodeError::TooLarge(_) => ("LimitError", "ELIMIT"),
        };
        js_error(name, code, &e.to_string()).into()
    }
}

#[wasm_bindgen]
pub fn decode2(bytes: &[u8]) -> Result<Vec<u8>, JsValue> {
    Ok(inner_decode2(bytes)?)            // DecodeError → JsValue via From
}

The core library stays testable with cargo test on the native target, and the JavaScript-facing error shape lives in one From impl.

The error path from Rust to a JavaScript catch block A Rust function returns a DecodeError. The question-mark operator converts it to a JsValue through the From impl, which builds a js_sys::Error with a name and code. wasm-bindgen's glue then throws that value, and the JavaScript catch block receives a real Error. inner_decode2(bytes)? returns Err(DecodeError::TooLarge) impl From<DecodeError> for JsValue builds js_sys::Error err.set_name("LimitError") name callers test Reflect::set(code, "ELIMIT") machine-readable code glue: if (r.is_err) throw r.err generated by wasm-bindgen catch (err) { err.name } "LimitError"

Step 4 — keep panics separate from errors

Result errors are for expected failures: bad input, missing data, limits. Panics are for bugs, and on wasm32-unknown-unknown a panic becomes a trap — a WebAssembly.RuntimeError: unreachable — not a nice error. Do not use unwrap() on anything that depends on input; convert those cases to Result. Install console_error_panic_hook so that genuine bugs at least log the panic message, and treat a RuntimeError in JavaScript as “the instance may be broken”, as covered in recovering a module after a trap.

The same applies to expect(), slice indexing with untrusted offsets, and integer arithmetic that can overflow in debug builds: each turns a bad input into a trap rather than an error. Audit the input-handling paths of exported functions for these, and use get(), checked_add() and friends there so that malformed data produces a named error the caller can handle.

Step 5 — type the errors for TypeScript callers

wasm-bindgen’s generated .d.ts declares the function’s return type but cannot express what it throws — TypeScript has no checked exceptions. Document the error names in a doc comment, which wasm-bindgen copies into the declaration, and export a small type guard from the JavaScript wrapper:

export function isDecodeError(e: unknown): e is Error & { code: string } {
  return e instanceof Error && (e.name === "FormatError" || e.name === "LimitError");
}

Errors from async functions

Async exports follow the same rules. An async fn returning Result<T, E> becomes a function that returns a Promise; Err(e) rejects the Promise with the converted value instead of throwing synchronously. Callers write await inside try, or attach .catch, and receive the same Error objects described above. Errors from awaited JavaScript Promises inside the Rust function arrive as Err(JsValue) — usually already a JavaScript Error — and can be propagated with ? unchanged, so a failed fetch inside Rust surfaces to the JavaScript caller as the original TypeError with its original message. Wrapping such errors to add context is often worth it — “loading model weights: TypeError: Failed to fetch” tells a reader far more than the bare message — and js_sys::Error::new_with_options with a cause keeps the original attached for debugging, matching the JavaScript { cause } convention.

Testing error behaviour from both sides

Errors are part of the API, so test them like the rest of it. On the Rust side, keep the core logic returning a plain Rust error type and test it with cargo test on the native target: every invalid input should produce the expected DecodeError variant. That runs in milliseconds and needs no browser. On the JavaScript side, a handful of wasm-bindgen-test or Vitest cases verify the conversion: the thrown value is an Error, name and code match the documented values, and the message is the human-readable text. Those two layers catch different regressions — the first catches logic bugs, the second catches a changed From impl or a forgotten map_err that suddenly throws a string again.

#[wasm_bindgen_test]
fn truncated_input_throws_format_error() {
    let err = decode2(&[0u8; 4]).unwrap_err();
    let err: js_sys::Error = err.dyn_into().expect("an Error object");
    assert_eq!(String::from(err.name()), "FormatError");
}

A useful convention is to list every error name and code a function can produce in its doc comment, and to have one test per entry. When someone adds a new failure case, the missing test makes the gap obvious, and the documentation stays in step with the behaviour callers actually see.

Expected output

try {
  decode2(new Uint8Array(4));
} catch (err) {
  console.log(err instanceof Error, err.name, err.code, err.message);
  // true "FormatError" "ETRUNC" "input shorter than header"
}

Gotchas

  • Throwing strings. Err("bad".into()) throws a string with no stack. Use js_sys::Error.
  • unwrap() on input-dependent data. It traps instead of throwing. Return Result.
  • Expecting instanceof on custom names. Setting name does not create a subclass. Test err.name.
  • Losing the original error. map_err(|_| ...) discards the cause. Include the source error’s message in the new one.
  • Returning #[wasm_bindgen] structs as errors. JavaScript receives a class instance that is not an Error, and it holds Wasm memory until freed.

Performance note

Constructing a js_sys::Error with a name and code costs a few boundary calls — about 3 µs per error in Chrome. On the success path, Result returns add nothing measurable over plain returns. Errors are exceptional; their cost matters only if a function fails in a hot loop, which suggests the failing case should be a normal return value instead.

Cost of returning an error from Rust Microseconds per call for a successful return, an error thrown as a string, and an error thrown as a js_sys::Error with name and code set. microseconds per call Ok return 0.1 µs Err as string 1.1 µs Err as named js_sys::Error 3.2 µs

Frequently Asked Questions

Can I create real subclasses like class FormatError extends Error? Define them in a JavaScript snippet imported with #[wasm_bindgen(module = ...)] and construct them from Rust; see importing JavaScript modules into Rust.

Does the stack trace include Rust frames? The Error is created inside the Wasm call, so the stack includes wasm-function frames, symbolicated if names are present.

What happens to anyhow::Error? Convert it to js_sys::Error with its to_string() or the alternate {:#} format to keep the context chain.

Do errors leak memory? No — the error is a JavaScript object, and Rust’s Err value is dropped normally before the glue throws.

Should I throw for “not found” results? Usually not. A lookup that legitimately finds nothing should return Option<T>, which becomes undefined; reserve thrown errors for failures the caller did not expect.

Can one function throw different error kinds? Yes — the From impl decides the name per variant, so one export can throw FormatError for some inputs and LimitError for others, and callers switch on err.name.

← Back to Errors & Traps Across the Boundary