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-packorwasm-bindgen-cli). - [ ]
js-sysas 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.
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.
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. Usejs_sys::Error. unwrap()on input-dependent data. It traps instead of throwing. ReturnResult.- Expecting
instanceofon custom names. Settingnamedoes not create a subclass. Testerr.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 anError, 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.
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.
Related
- Mapping C error codes to JavaScript errors — the same goal for C modules.
- Debugging unreachable-executed traps — when it panics instead.
- Customising TypeScript output from wasm-bindgen — documenting thrown errors.
- Awaiting JavaScript promises from Rust — async errors in both directions.
← Back to Errors & Traps Across the Boundary