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

There are three distinct ways to accept a JavaScript object, and choosing between them is really a choice about where the conversion cost lands. Taking a JsValue and reading fields through the Reflect API converts nothing up front and pays per property access — good when you need one field out of a large object. Deserialising with serde-wasm-bindgen converts the whole object once into a native Rust type — good when you will touch most of it, and the only option that gives you real types. Accepting a typed array or a pointer avoids the conversation entirely and is what you want for bulk numeric data.

The mistake worth avoiding is defaulting to deserialisation everywhere. A per-frame call that deserialises a large options object spends its time in the conversion layer rather than in your algorithm, and the fix — hoisting the options out of the loop, or passing an index instead — is much easier to apply before the API has callers.

Where the conversion cost lands Holding a JsValue and reflecting per field converts nothing up front. Deserialising converts the whole object once into a typed Rust value. Passing a typed array or pointer skips conversion entirely and is the right shape for bulk data. JsValue + Reflect no up-front conversion one crossing per field read no static types, runtime errors best when you need one or two fields from a large object serde-wasm-bindgen whole object converted once real Rust types afterwards cost scales with object size best when you use most of it, and not once per frame typed array or pointer no object conversion at all bytes already where Rust wants them you handle layout yourself best for bulk numeric data and anything in a hot loop All three are correct; only one is right for a given call site, and call frequency is the deciding factor more often than object shape.

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 }
Where a bad object shows up Deserialising validates the whole object at the boundary and names the offending field. Reading fields reflectively accepts anything and produces a None or a default, so a missing property is discovered much later and further from its cause. deserialise at the boundary a missing or mistyped field fails here "invalid type: string, expected u32 at .width" names the field and the expectation one place to handle the error reflect per field a missing field yields None or a default execution continues with wrong data the symptom appears far from the cause so validate explicitly if you take this path The performance argument for reflection is real, but it moves error handling from the boundary into your logic — budget for that.

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.

Convert once, call many times Passing an options object on every frame deserialises it on every frame. Converting it once into a Rust struct held by the module, and passing only what changes, removes the conversion from the hot path entirely. options passed every frame conversion dominates the frame configured once, then frames the module keeps the parsed config conversion actual work — the same shape as every other batching decision on this boundary

Returning objects the other way

The same three options apply in reverse, with one asymmetry worth knowing. Serialising a Rust struct back to a JavaScript object with serde-wasm-bindgen produces a plain object with the field names you declared, which is convenient and costs a conversion proportional to the object’s size. Returning a #[wasm_bindgen] struct instead produces a class instance backed by a pointer — no conversion at all, and field access goes through generated getters that cross the boundary individually.

That difference matters more than it first appears. A returned class is cheap to produce and expensive to read field by field; a serialised object is the opposite. So a result the caller will inspect once, or spread into application state, is better serialised. A result the caller will hold and query occasionally — a parser context, a handle to a long-lived computation — is better as a class, with the reminder that it carries a free() the caller must call.

Where the result is bulk numeric data, neither is right: return a pointer and a length and let the caller build a view, exactly as for input. That keeps large results out of the conversion layer entirely, and it is the one case where the answer does not depend on how the caller intends to use the value.

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