Appearance
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:
Serializetrait — your struct says "here are my fields, in this order, with these names." Format-agnostic.- 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 callsBoth 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 stringts
// 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 directlyts
// 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.parsein JS. Why bother withserde-wasm-bindgen?"Tell them, specifically:
- Per call, what extra work the JSON-string path does that the
to_valuepath skips entirely.- Why the trait separation (
Serializederived once, adapter chosen separately) is what makesto_valueeven possible without rewriting your structs.
User's answer (PASS)
- We would have to convert to string and from string to JS object. The serde-bridge directly converts rust object to JS object.
- 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.