Passing JS Objects to Rust with wasm-bindgen

This guide shows how to take an arbitrary JavaScript object — { name: "Ada", age: 36, tags: [...] } — and turn it into a typed Rust struct inside a wasm-bindgen export, using serde-wasm-bindgen to do the field-by-field deserialization across the boundary.

A WebAssembly function signature only accepts numbers, so a JavaScript object cannot cross directly. Two options exist: receive it as an opaque JsValue and pluck fields one at a time, or deserialize the whole thing into a Rust struct in one call. This guide does the second — it is the ergonomic default — and notes when the first is faster.

Under the hood, the object never enters linear memory as a single blob. The glue passes Rust a JsValue that is just an integer index into a JavaScript-side table of references, and serde-wasm-bindgen reaches back across the boundary field by field — calling the equivalent of Reflect.get for each key, then copying each scalar into Rust-owned memory. Knowing that shape is what makes the error messages and the performance characteristics below predictable rather than mysterious.

Prerequisites

  • [ ] Rust 1.78+ with the wasm32-unknown-unknown target
  • [ ] wasm-pack 0.12+ and a version-matched wasm-bindgen-cli
  • [ ] serde = { version = "1", features = ["derive"] } and serde-wasm-bindgen = "0.6" in Cargo.toml
  • [ ] Familiarity with the generated glue from the wasm-bindgen deep dive

Procedure

  1. Declare the Rust struct and derive Deserialize. The field names must match the JavaScript object’s keys exactly (or be remapped with #[serde(rename = "...")]).

    use serde::Deserialize;
    use wasm_bindgen::prelude::*;
    
    #[derive(Deserialize)]
    pub struct User {
        name: String,
        age: u32,
        #[serde(default)]
        tags: Vec<String>,
    }
  2. Accept the object as a JsValue. This is the opaque handle the glue passes — a table index, not bytes. Deserialize it inside the function and return a Result so failures surface as JavaScript exceptions rather than panics.

    #[wasm_bindgen]
    pub fn summarize(input: JsValue) -> Result<String, JsValue> {
        let user: User = serde_wasm_bindgen::from_value(input)?;
        Ok(format!("{} ({}) has {} tags", user.name, user.age, user.tags.len()))
    }

    The ? converts a serde_wasm_bindgen::Error into a JsValue automatically, so a malformed object throws a readable Error on the JavaScript side instead of trapping.

  3. Build the module.

    wasm-pack build --target web --out-dir pkg
  4. Call it from JavaScript with a plain object literal. No manual encoding — pass the object straight in; the glue hands its JsValue index to Rust, and serde-wasm-bindgen walks it.

    import init, { summarize } from "./pkg/my_crate.js";
    
    await init();
    const text = summarize({ name: "Ada", age: 36, tags: ["math", "wasm"] });
    console.log(text);
  5. Round-trip a struct back out (optional). Derive Serialize too and return a JsValue built with to_value, giving JavaScript a plain object rather than a wrapped class.

    use serde::Serialize;
    
    #[derive(Serialize)]
    struct Summary { label: String, tag_count: usize }
    
    #[wasm_bindgen]
    pub fn summarize_obj(input: JsValue) -> Result<JsValue, JsValue> {
        let user: User = serde_wasm_bindgen::from_value(input)?;
        let out = Summary { label: user.name, tag_count: user.tags.len() };
        Ok(serde_wasm_bindgen::to_value(&out)?)
    }

Expected output

The console log from step 4:

Ada (36) has 2 tags

And summarize_obj({ name: "Ada", age: 36, tags: ["math", "wasm"] }) returns a live JavaScript object:

{ label: "Ada", tag_count: 2 }

Gotchas

invalid type: map, expected a string — your Rust field is a String but the JavaScript value at that key is an object (or vice versa). Serde reports the JavaScript shape it found (“map” for an object) against the Rust type it expected. Fix the type mismatch, or wrap the field in Option<T> if it is genuinely optional.

**missing field \age`** — the JavaScript object omitted a key that the struct requires. Add #[serde(default)]to make the field optional with itsDefault, or mark it Optionto acceptundefined/missing as None`.

**invalid type: floating point \36.0`, expected u32** — every JavaScript number is an IEEE-754 double, so serde-wasm-bindgensees36.0, not 36. By default it deserializes cleanly to integers when the value is integral, but a fractional value will fail. If you control the data, prefer f64` in the struct; if you need an integer, ensure the JavaScript side passes whole numbers.

JsValue(undefined) thrown with no message — you passed undefined or null where an object was expected. Guard on the JavaScript side, or accept Option<User> and handle the empty case in Rust.

Performance note

serde_wasm_bindgen::from_value walks the entire object graph and copies every field into Rust-owned memory in linear memory. For a small struct called occasionally this is a few microseconds and not worth optimizing. For a hot path — thousands of objects per frame — the copy plus the per-field Reflect.get lookups dominate. In that case skip serde and pass flat primitives (fn f(name: &str, age: u32)) or, for large numeric payloads, hand over a single typed array and a length and read it as a zero-copy view. As a rough order of magnitude, deserializing a 10-field object measures around 1–3 µs, versus tens of nanoseconds for an equivalent set of primitive arguments.

The cost scales with field count and nesting depth, not just object size in bytes, because each property access is its own boundary call. A flat object with twenty scalar fields is cheaper than a deeply nested one with five, even if the byte totals match. There is also a second, hidden cost: every String and Vec field allocates in the module’s heap, so a deserialize-heavy loop puts pressure on the allocator and can trigger a memory.grow that detaches existing typed-array views on the JavaScript side. If you are deserializing in a tight loop and also holding views over linear memory, re-create those views after the calls, or move the work to a coarser function that deserializes once and processes a batch.

Frequently Asked Questions

Should I use serde-wasm-bindgen or the older JsValue::into_serde? Use serde-wasm-bindgen. The JsValue::into_serde/from_serde methods route through a JSON string and are deprecated; serde-wasm-bindgen walks the live object directly, which is both faster and preserves types like Map, BigInt, and Uint8Array that JSON would mangle.

How do I accept either an object or a string for the same parameter? Deserialize into a Serde enum with #[serde(untagged)], or take a JsValue and branch on value.is_string() before deciding how to interpret it. The untagged enum is cleaner when the variants are well defined.

Can I deserialize a JavaScript Map instead of a plain object? Yes — serde-wasm-bindgen maps a JavaScript Map onto Serde’s map model, so a HashMap<String, T> field deserializes from a real Map. Set the deserializer’s serde_as_map option if you also want plain objects treated as maps.

← Back to wasm-bindgen Deep Dive