Propagating Rust Results to JavaScript
This guide answers one task: take a Rust function that returns Result and expose it so that a JavaScript
caller gets a usable error rather than a trap — without dragging formatting machinery into the module.
Prerequisites
- [ ] A Rust crate using
wasm-bindgen. - [ ] Functions that return
Resultrather than panicking. - [ ] A decision about whether errors should be thrown or returned to the caller.
- [ ] A size budget, because the answer affects it.
What wasm-bindgen does with a Result
An exported function returning Result<T, E> where E: Into<JsValue> becomes a JavaScript function that
returns T on success and throws on failure.
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn parse_config(input: &str) -> Result<JsValue, JsValue> {
let cfg: Config = serde_json::from_str(input)
.map_err(|e| JsValue::from_str(&e.to_string()))?;
Ok(serde_wasm_bindgen::to_value(&cfg)?)
}
try {
const cfg = parse_config(text);
} catch (e) {
console.log(e); // the string from the Err branch
}
That is convenient and has a cost worth knowing: e.to_string() pulls core::fmt into the module, which
for a small module is often larger than everything else. The alternatives below avoid it.
Async functions and rejected promises
An exported async fn returning a Result becomes a function returning a promise, and the Err branch
becomes a rejection rather than a synchronous throw.
#[wasm_bindgen]
pub async fn fetch_and_parse(url: String) -> Result<JsValue, JsValue> {
let resp = fetch(&url).await?; // JsValue error propagates
let text = resp_text(resp).await?;
let cfg: Config = serde_json::from_str(&text).map_err(|_| JsValue::from_f64(-1003.0))?;
Ok(serde_wasm_bindgen::to_value(&cfg)?)
}
try {
const cfg = await fetch_and_parse(url);
} catch (e) {
// the rejection value, same shape as the synchronous case
}
That symmetry is useful: the same error vocabulary works for both, and a caller does not have to know whether a particular export is asynchronous to handle its failures.
One asymmetry to watch for is an unhandled rejection. A synchronous throw that nobody catches produces a
console error immediately; a rejected promise that nobody awaits produces an unhandled rejection warning
later, sometimes much later, and in a place that does not name the call. Awaiting every call — or attaching
a catch — is worth being disciplined about for exactly that reason.
A structured error, not a string
A string error is easy to produce and awkward to consume: the caller cannot branch on it without parsing prose, and the prose is not translatable. A structured error solves both.
#[derive(serde::Serialize)]
pub struct AppError {
pub code: &'static str,
pub field: Option<String>,
}
impl From<AppError> for JsValue {
fn from(e: AppError) -> JsValue { serde_wasm_bindgen::to_value(&e).unwrap() }
}
#[wasm_bindgen]
pub fn validate(input: &str) -> Result<(), AppError> {
if input.is_empty() {
return Err(AppError { code: "empty", field: Some("name".into()) });
}
Ok(())
}
try {
validate('');
} catch (e) {
// { code: 'empty', field: 'name' }
showFieldError(e.field, MESSAGES[e.code]);
}
The code is a stable identifier the interface maps to a message, so the wording lives in the application where translators can reach it and the module stays free of strings.
Avoiding core::fmt entirely
For a size-sensitive module, the bigger win is not returning strings at all. An integer code carries the same information and pulls in no formatting machinery.
#[wasm_bindgen]
pub fn validate_code(input: &str) -> i32 {
if input.is_empty() { return -101; }
if input.len() > MAX { return -102; }
0
}
const code = validate_code(value);
if (code !== 0) showFieldError(MESSAGES[code] ?? 'Invalid value');
Measured on a small module, switching from string errors to integer codes removed 14 kB compressed — 34%
of the binary — because it eliminated core::fmt and its dependencies. For a module whose only use of
formatting was error messages, that is the single largest size change available.
Where a message genuinely must come from the module — because it contains data the host does not have — a middle path is to return a code plus a small payload of the values, and let the host do the formatting.
Errors from ? and foreign types
The ? operator needs a conversion from each error type into your error type, which for a module with
several dependencies means a few From implementations.
#[derive(Debug)]
pub enum Error { Parse(u32), Io, Limit }
impl From<serde_json::Error> for Error {
fn from(e: serde_json::Error) -> Self { Error::Parse(e.line() as u32) }
}
impl From<Error> for JsValue {
fn from(e: Error) -> JsValue {
JsValue::from_f64(match e {
Error::Parse(line) => -(1000 + line as f64),
Error::Io => -2000.0,
Error::Limit => -3000.0,
})
}
}
Converting at the boundary rather than carrying a foreign error type through the module keeps the interface
stable when a dependency changes its error type, which it will. It also keeps the foreign type’s Display
implementation — and the formatting it drags in — out of the build.
Keeping the error path testable
Error paths are the least exercised part of a module, and testing them natively is much cheaper than testing them through the boundary.
Structure the code so the fallible logic is a pure function returning Result and the exported wrapper is
a thin conversion. The logic’s error cases then have ordinary Rust tests, and only the conversion needs a
browser test.
// crates/core — tested natively, in milliseconds
pub fn validate(input: &str) -> Result<Validated, Error> { … }
#[cfg(test)]
mod tests {
#[test] fn empty_is_rejected() { assert!(matches!(validate(""), Err(Error::Empty))); }
#[test] fn oversized_is_rejected() { assert!(matches!(validate(&"x".repeat(MAX + 1)), Err(Error::Limit))); }
}
// the wrapper — one browser test covers the conversion for all cases
#[wasm_bindgen]
pub fn validate_js(input: &str) -> Result<JsValue, JsValue> {
core::validate(input).map(into_js).map_err(into_js_err)
}
The browser test then asserts one success and one failure, confirming that the conversion works and that the shape the caller receives is what the interface documents. Everything else — every individual error case, every boundary condition — is covered natively where the loop is fast.
That split also keeps the conversion honest. A single into_js_err function converting every variant means
a new variant produces a compile error until it is handled, rather than silently arriving at the caller as
something unexpected.
Expected output
The three approaches, from a caller’s point of view:
// string error
catch → "expected value at line 3 column 12" module +14 kB
// structured error
catch → { code: 'parse', line: 3 } module +6 kB
// integer code
returns -1003 module +0 kB
All three are correct, and the choice is a trade between how much the caller learns and how much the module weighs. For a library consumed by other developers the structured form is usually right; for a size-critical module in your own application, codes.
Gotchas
- Throwing for expected conditions. A
catchon every call is a worse interface than a returned status for something that happens routinely. unwrap()in an exported function. Turns a recoverable error into a trap and takes the instance with it.- String errors in a size-sensitive module.
core::fmtis frequently the largest thing in the binary. - A foreign error type in the signature. Ties your interface to a dependency’s versioning.
- Losing the error in
?conversion. AFromthat discards the detail makes the failure undiagnosable; keep at least a code. - Assuming a thrown value is an
Error.JsValuecan be anything; callers doinge.messagegetundefinedif you threw a number.
Performance note
For a validation module, returning integer codes rather than string errors reduced the compressed binary
from 41 kB to 27 kB — the whole of core::fmt and its dependencies. Call overhead was unchanged: both
return a value, and neither traps. The only cost was a lookup table of messages on the host side, which
compresses to a few hundred bytes and lives with the rest of the interface’s text.
Frequently Asked Questions
Does serde_wasm_bindgen add much?
It adds the serialisation machinery for whatever types you convert, which for a small structured error is a
few kilobytes and for a rich object graph considerably more. Weigh it against the integer-code alternative
if size matters.
Should exported functions throw or return a status? Throw for genuinely exceptional conditions, return for expected ones. A parse failure on user input is expected; a corrupt internal state is not. Consistency matters more than the specific line.
Can I preserve a Rust backtrace across the boundary? Not usefully. A backtrace requires the unwinding machinery a WebAssembly build does not have, and the stack the host sees is the WebAssembly one. Carry a code identifying where the error originated instead — it is smaller and, for a compiled module, more informative.
How do I return both a value and a warning?
Return a structure containing both. Overloading the error channel to carry non-fatal information makes
every caller handle a success case inside a catch.
Does anyhow or thiserror work here?
They compile, and they bring formatting with them. For a server-side WebAssembly module that is usually
fine; for a browser module it is the size decision described above.
Related
- Returning error codes without exceptions — the code-based convention in full.
- Handling panics in Rust Wasm — what happens when you do not return a
Result. - Shrinking Rust Wasm with cargo profiles — the size context for these choices.
Whichever shape you choose, choose one and apply it across the whole interface. Consistency is worth more to a caller than any individual design decision on this page.
← Back to Errors & Traps Across the Boundary