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 Result rather 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.

Ok returns, Err throws An exported function returning a Result produces a normal return value on the Ok branch and throws the error value on the Err branch, so a JavaScript caller uses an ordinary try and catch. Result<JsValue, JsValue> Ok Err returns the value ordinary call site throws the error value caught with try / catch Both branches return normally from WebAssembly's point of view — no trap occurs, and the instance stays healthy either way.

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.

Convert once, at the edge Errors from several dependencies convert into a single module error type, which converts once more into the value the host receives. A dependency changing its error type affects one conversion rather than the whole interface. serde_json::Error std::str::Utf8Error TryFromIntError From enum Error your vocabulary code or structured value what the host sees One conversion per dependency and one at the boundary — a dependency upgrade touches a single line rather than every call site.

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.

Two ways a Result can land Returning Result from an exported function makes the error a thrown JsValue. Returning a tagged value instead keeps the failure as data the caller inspects. Result<T, JsValue> Err becomes a throw the caller needs try/catch a tagged return value Err becomes a field the caller checks ok before reading value Throwing suits genuinely exceptional failures; a tagged value suits a failure the caller expects. Whichever you pick, be consistent across the module — a mixed surface is the hardest kind to call.

Gotchas

  • Throwing for expected conditions. A catch on 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::fmt is 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. A From that discards the detail makes the failure undiagnosable; keep at least a code.
  • Assuming a thrown value is an Error. JsValue can be anything; callers doing e.message get undefined if 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.

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