Skip to content

S1: serde-wasm-bindgen — turning Rust structs into real JS objects

Taught: 2026-04-29 (during M1.5 Debug Overlay milestone) Milestone: Phase 1 M1.5 — Debug Overlay Result: PASS (after conversational re-teach; original compact format was rejected by the user) Backfilled: 2026-05-04

Why this concept matters here

The M1.5 debug overlay needs to return structured data (multiple fields like sphere_radius, cam_yaw, cam_pitch, cam_distance) from Rust to JavaScript so the HUD can display live scene state. Naively this can be done via JSON strings and JSON.parse, but serde-wasm-bindgen::to_value provides a cleaner, faster path: it converts a Serialize-derived Rust struct directly into a real JS object via FFI, no string round-trip. Understanding why this is better than JSON exposes the trait-based design pattern that makes serde compose across many output formats.

The walkthrough

Step 1: What is serde at all?

serde is a Rust library that solves one specific problem: converting between Rust structs and "some external format" — JSON, YAML, binary bytes, MessagePack, anything.

The core insight of serde's design: the struct shouldn't know what format it's being converted to. Your Point struct shouldn't have a to_json() method, a to_yaml() method, a to_bincode() method. That's 1000 methods if you support 1000 formats.

Instead, serde splits the work into two halves:

  1. Serialize trait — your struct says "here are my fields, in this order, with these names." Format-agnostic.
  2. A serializer adapter — knows how to take that field-by-field description and write it out as JSON / YAML / a JS object / whatever.

Your struct opts into part 1 with #[derive(Serialize)]. The adapter is a separate crate.

Step 2: What does #[derive(Serialize)] actually generate?

When you write:

rust
#[derive(serde::Serialize)]
struct SceneStateJs {
    sphere_radius: f32,
    cam_yaw: f32,
}

The derive macro expands at compile time into roughly this:

rust
impl Serialize for SceneStateJs {
    fn serialize<S: Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
        let mut state = ser.serialize_struct("SceneStateJs", 2)?;
        state.serialize_field("sphere_radius", &self.sphere_radius)?;
        state.serialize_field("cam_yaw", &self.cam_yaw)?;
        state.end()
    }
}

It's a recipe: "I'm a struct with 2 fields named these things, here are their values." Notice it doesn't say JSON anywhere — it just calls methods on a generic Serializer.

Step 3: Two different adapters, one Serialize impl

The same SceneStateJs can be passed to:

rust
let json_string = serde_json::to_string(&state)?;
//                ^^^^^^^^^^^^^^^^^^^^^^
//                builds a String like '{"sphere_radius":1.5,"cam_yaw":0.3}'

let js_object = serde_wasm_bindgen::to_value(&state)?;
//              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
//              builds a real JS object via wasm-bindgen FFI calls

Both use the same Serialize impl the macro generated. They just walk it differently.

serde_json::to_string walks the fields and writes ASCII characters into a String. serde_wasm_bindgen::to_value walks the fields and calls js_sys::Object::set for each one, building a live JS object.

Step 4: Why does it matter for our case?

Path A — JSON string roundtrip:

rust
// Rust (every call):
let s = SceneStateJs { ... };
let json: String = serde_json::to_string(&s)?;     // build string
return JsValue::from_str(&json);                    // copy into JS heap as string
ts
// JS (every call):
const json = engine.get_scene_state();              // get string
const s = JSON.parse(json);                         // parse it back into object
overlay.textContent = `r=${s.sphere_radius}`;

What runs every time you call get_scene_state():

  • Rust: serialize → string (alloc + format)
  • FFI: copy string bytes Rust→JS
  • JS: JSON.parse (allocates JS object from string)

Path B — serde_wasm_bindgen::to_value:

rust
// Rust (every call):
let s = SceneStateJs { ... };
serde_wasm_bindgen::to_value(&s)                    // builds JS object directly
ts
// JS (every call):
const s = engine.get_scene_state();                 // already a JS object
overlay.textContent = `r=${s.sphere_radius}`;

What runs every call:

  • Rust: walk fields, push native values across FFI as a built-up JS object

The difference: Path A serializes to bytes, copies the bytes, then deserializes back. Path B skips the string altogether — it builds the JS object directly. No JSON.parse per call.

We're polling 5× at 10 Hz = 50 calls/sec. Both paths work, but Path B is cheaper per call and avoids the conceptual ugliness of "I have a struct → convert to text → convert back to a struct."

Step 5: The "trait-based design" point

The reason this all works is that Serialize is a trait — an interface — not a hardcoded format. Your SceneStateJs describes itself once with #[derive(Serialize)], and it's now compatible with every serde adapter that exists or will exist:

  • Want JSON for an HTTP response? serde_json
  • Want a binary file? bincode
  • Want a JS object? serde_wasm_bindgen
  • Want CBOR? serde_cbor

If serde had been designed without a trait — say, every struct just had a to_json() method baked in — then to support JS objects you'd have to add a second method everywhere, and to support binary you'd add a third, etc. The trait pulls the format choice out of the struct so adapters can compose freely.

This is the same lesson as polymorphism in any OO language, just applied to data serialization.

The mental model in one sentence

#[derive(Serialize)] writes a generic "describe my fields" recipe once; format-specific adapters (serde_json, serde-wasm-bindgen, bincode, ...) each walk that recipe differently to emit their format, so the same struct is compatible with every adapter that ever exists.

Explain-back question

A junior engineer says: "Just return JSON as a string and call JSON.parse in JS. Why bother with serde-wasm-bindgen?"

Tell them, specifically:

  1. Per call, what extra work the JSON-string path does that the to_value path skips entirely.
  2. Why the trait separation (Serialize derived once, adapter chosen separately) is what makes to_value even possible without rewriting your structs.

User's answer (PASS)

  1. We would have to convert to string and from string to JS object. The serde-bridge directly converts rust object to JS object.
  2. Yeah, trait separation makes the serialization format agnostic!

Judgment

PASS. Articulated:

  • Per-call cost: identified the two-step conversion (Rust→string→JS object) vs one-step (Rust→JS object) — got the COST asymmetry, the WHY of using to_value
  • Trait architecture: identified that the trait separation makes serialization format-agnostic — got the WHY of the decoupling

Showed WHY, not just WHAT. Core mental model intact.

Conversational walkthroughs of real-time graphics + Rust/WASM concepts.