wasm-bindgen Deep Dive: Crossing the Rust/JavaScript Boundary
22 min read
wasm-bindgen is the glue layer that makes Rust-in-the-browser feel like a normal JavaScript module. Raw WebAssembly can only pass a handful of numeric types back and forth; wasm-bindgen lets you exchange strings, structs, arrays, Option, errors that throw, and even closures you can register as event handlers. This page is the conceptual core of the section: exactly which types can cross the boundary, how they are represented on each side, what JsValue is, when to reach for serde-wasm-bindgen, and how to hand a Rust closure to JavaScript without leaking memory or crashing.
Quick Overview
Section titled “Quick Overview”WebAssembly’s own type system is tiny: a function can only take and return i32, i64, f32, and f64. Everything richer — a String, a Uint8Array, a callback — has to be encoded into those primitives and decoded on the other side. wasm-bindgen generates that encode/decode glue automatically from the #[wasm_bindgen] attribute, plus a .js shim and a .d.ts TypeScript declaration file so the module is fully typed when you import it. For a TypeScript developer the mental model is “a typed FFI compiler”: you annotate Rust, and you get a JavaScript module whose types line up with the Rust ones — but only for the set of types wasm-bindgen knows how to translate. Anything outside that set travels as a JsValue (its any/unknown equivalent) or via serde.
TypeScript/JavaScript Example
Section titled “TypeScript/JavaScript Example”Today, when a TypeScript team wants a hot loop to run faster, they often ship it as a separately compiled module and call it across a boundary — exactly the situation wasm-bindgen automates. Here is the shape of that interaction written entirely in TypeScript: a typed module that exchanges primitives, objects, and a callback.
// geometry.ts — a module we import and call. Note every value that crosses// the call is either a primitive, a plain object, or a function.
export interface Point { x: number; y: number; label: string;}
export function distanceFromOrigin(p: Point): number { return Math.sqrt(p.x * p.x + p.y * p.y);}
export function parseAge(input: string): number { const n = Number(input.trim()); if (!Number.isInteger(n) || n < 0) { throw new Error(`invalid age: ${input}`); } return n;}
// Register a callback that JavaScript will invoke later.export function startTicker(onTick: (count: number) => void): number { let count = 0; return setInterval(() => onTick(++count), 1000);}import { distanceFromOrigin, parseAge, startTicker } from "./geometry";
console.log(distanceFromOrigin({ x: 3, y: 4, label: "p" })); // 5console.log(parseAge(" 42 ")); // 42const id = startTicker((n) => console.log(`tick ${n}`)); // tick 1, tick 2, ...Three things matter here, because each maps onto a different wasm-bindgen mechanism: a plain object crosses by value (Point), an error propagates by throwing (parseAge), and a callback is handed over and stored to be called later (startTicker). Crossing into Rust, each needs explicit handling.
Rust Equivalent
Section titled “Rust Equivalent”Create a library crate (WASM modules are libraries, not binaries) and add the boundary crates. The current stable toolchain is Rust 1.96.0 on the 2024 edition; cargo new selects it automatically.
cargo new --lib geometrycd geometrycargo add wasm-bindgencargo add serde --features derivecargo add serde-wasm-bindgenA WASM library must be built as a cdylib, so set the crate type in Cargo.toml (this is covered in detail in wasm-pack.md):
[lib]crate-type = ["cdylib", "rlib"]
[dependencies]wasm-bindgen = "0.2.122"serde = { version = "1.0.228", features = ["derive"] }serde-wasm-bindgen = "0.6.5"Now src/lib.rs — the same three interactions, idiomatic and compile-verified against wasm32-unknown-unknown:
use wasm_bindgen::prelude::*;use serde::{Deserialize, Serialize};
// A rich struct travels via serde-wasm-bindgen as a plain JS object.#[derive(Serialize, Deserialize)]pub struct Point { x: f64, y: f64, label: String,}
#[wasm_bindgen]pub fn distance_from_origin(value: JsValue) -> Result<f64, JsValue> { // Deserialize the incoming JS object into our Rust struct. let p: Point = serde_wasm_bindgen::from_value(value)?; Ok((p.x * p.x + p.y * p.y).sqrt())}
// Result<T, JsValue>: Ok(t) returns t to JS, Err(e) becomes a thrown exception.#[wasm_bindgen]pub fn parse_age(s: &str) -> Result<u32, JsValue> { s.trim() .parse::<u32>() .map_err(|e| JsValue::from_str(&format!("invalid age: {e}")))}
// Import the JS timer and console functions we need.#[wasm_bindgen]extern "C" { #[wasm_bindgen(js_namespace = console)] fn log(s: &str); fn setInterval(cb: &Closure<dyn FnMut()>, ms: f64) -> f64;}
// The Closure must outlive the timer, so the Ticker owns it.#[wasm_bindgen]pub struct Ticker { _closure: Closure<dyn FnMut()>, id: f64,}
#[wasm_bindgen]impl Ticker { #[wasm_bindgen(constructor)] pub fn new() -> Ticker { let mut count = 0u32; let closure = Closure::new(move || { count += 1; log(&format!("tick {count}")); }); let id = setInterval(&closure, 1000.0); Ticker { _closure: closure, id } }
#[wasm_bindgen(getter)] pub fn id(&self) -> f64 { self.id }}Building this against the WASM target compiles cleanly:
$ cargo build --target wasm32-unknown-unknown Compiling geometry v0.1.0 (/.../geometry) Finished `dev` profile [unoptimized + debuginfo] target(s) in 6.13sNote: Plain
cargo buildtype-checks the WASM code on your host platform too, but only thewasm32-unknown-unknowntarget produces a real.wasm. In practice you runwasm-pack build(see wasm-pack.md), which compiles and runs thewasm-bindgenCLI to emit the.js+.d.tsglue in one step.
Detailed Explanation
Section titled “Detailed Explanation”What wasm-bindgen actually does
Section titled “What wasm-bindgen actually does”The #[wasm_bindgen] attribute is a procedural macro. For every exported function it generates a second, ABI-flattened function whose arguments are only the WASM-native numeric types, plus the encode/decode logic. A &str argument, for example, is not passed as a “string” — there is no such WASM type. Instead the generated JavaScript copies the string’s UTF-8 bytes into the WASM module’s linear memory, passes a (pointer, length) pair of i32s, and the Rust side reconstructs a &str view over those bytes. The .d.ts file it emits still says string, so the boundary is fully typed from TypeScript’s perspective; the byte-copying is invisible.
This is the first big conceptual difference from a TypeScript module: a function call across the boundary is not free. Primitives (number, boolean) pass as a single register and are essentially zero-cost, but every string, array, or object involves copying bytes into or out of WASM memory. The performance consequences of that are the subject of performance.md; here the point is simply that the type determines the cost.
The type translation table
Section titled “The type translation table”wasm-bindgen knows how to translate a fixed, well-defined set of Rust types. The most important ones:
| Rust type | JavaScript / TypeScript type | How it crosses |
|---|---|---|
i8..i32, u8..u32, f32, f64 | number | by value (one register) |
i64 / u64 / i128 / u128 | bigint | by value; not number |
bool | boolean | by value |
char | string (length 1) | copied |
String / &str | string | UTF-8 bytes copied |
Vec<u8> / &[u8] | Uint8Array | bytes copied |
Vec<i32>, Vec<f64>, … | Int32Array, Float64Array, … | copied |
Vec<String> | string[] | copied |
Option<T> | T | undefined | sentinel value |
Result<T, JsValue> | T or a thrown exception | see below |
#[wasm_bindgen] struct | a class instance (opaque handle) | by reference (pointer) |
JsValue | any JS value | by reference (handle) |
Two rows surprise TypeScript developers the most. First, u64/i64 map to bigint, not number — because a JavaScript number is always an IEEE-754 f64 and silently loses precision above 2^53, whereas Rust’s 64-bit integers are exact. wasm-bindgen refuses to lie about that and uses bigint. Second, a #[wasm_bindgen] struct does not cross as a plain object the way a TypeScript class instance would when structured-cloned; it stays inside WASM memory and JavaScript receives an opaque handle (a small class wrapping a pointer). We unpack that distinction in Key Differences.
Option and Result
Section titled “Option and Result”Option<T> becomes T | undefined: Some(v) is v, None is undefined. This is the cleanest analogue to TypeScript’s optional values, and it is what you usually want instead of inventing a null-object.
Result<T, JsValue> is how you raise an exception in JavaScript from Rust. Ok(t) returns t normally; Err(e) is thrown — the JavaScript caller sees try { ... } catch (e) { ... } semantics, with e being whatever JsValue you put in the Err. In parse_age, a parse failure becomes JsValue::from_str("invalid age: ..."), which JavaScript receives as a thrown string. (To throw a real Error object instead of a bare string, construct a js_sys::Error — see js-interop.md.)
JsValue: the any/unknown of the boundary
Section titled “JsValue: the any/unknown of the boundary”JsValue is an opaque handle to any JavaScript value living on the JS side of the heap — a number, an object, a function, a DOM node, anything. Rust cannot inspect its fields directly; it can only call the conversion helpers (JsValue::from_str, as_f64(), as_bool(), is_null(), …) or hand it to js-sys / web-sys accessors. Think of it as TypeScript’s unknown: you hold it safely, but you must narrow it before you can do anything specific. When a function signature says JsValue, wasm-bindgen performs no copying and no validation — it just passes the handle through, which is exactly what echo below does:
use wasm_bindgen::prelude::*;
// The identity function over arbitrary JS values — no copy, no inspection.#[wasm_bindgen]pub fn echo(input: JsValue) -> JsValue { input}serde-wasm-bindgen: rich structs without writing glue
Section titled “serde-wasm-bindgen: rich structs without writing glue”When you want to pass a structured value (an object with named fields, nested arrays, enums) you have two options. You can annotate the struct with #[wasm_bindgen] and expose getters/setters one field at a time — verbose, and it produces an opaque handle, not a plain object. Or, far more often, you derive serde::{Serialize, Deserialize} and use serde-wasm-bindgen to convert between your Rust type and a real JavaScript object in one call:
serde_wasm_bindgen::to_value(&rust_value)→JsValue(a plain JS object/array)serde_wasm_bindgen::from_value::<T>(js_value)→Result<T, Error>
That is exactly the Point round-trip above. The incoming { x, y, label } object is deserialized into a Rust Point; the result is computed in Rust. This is the WASM-boundary cousin of the Serde JSON workflow you already know from Section 15 — same derives, same mental model — except the target is a live JS object graph rather than a JSON string, so no text parsing happens.
Tip:
serde-wasm-bindgenreplaced the oldJsValue::from_serde/into_serdemethods that lived behindwasm-bindgen’sserde-serializefeature. Those are deprecated; reach for theserde-wasm-bindgencrate, which is faster (it builds JS objects directly instead of going through a JSON string) and correctly handles maps,u64, and byte arrays.
Closures: the hardest part of the boundary
Section titled “Closures: the hardest part of the boundary”A Rust closure cannot be handed to JavaScript directly, because JavaScript needs a callable function object and Rust needs to keep the closure’s captured environment alive. wasm_bindgen::closure::Closure bridges the two: Closure::new(f) allocates a JS function that, when called, jumps back into the Rust closure. The catch — and this is where almost every newcomer trips — is ownership and lifetime:
- The JS function is valid only while the
Closureis alive in Rust. If theClosureis dropped, the JS function becomes dangling, and calling it throwsclosure invoked recursively or after being dropped. - A
Closurethat is registered for repeated calls (an interval, an event listener) must therefore be stored somewhere that outlives those calls. InTicker, the struct field_closureholds it for as long as theTickerexists. Closure::newacceptsFnMut/Fnclosures for repeated calls;Closure::onceis for a callback that fires exactly once and then frees itself.
The deliberately-ugly _closure field (leading underscore to silence the “unused” warning) is idiomatic: it documents that the field exists purely to keep the callback alive. The alternative — closure.forget() — leaks the closure on purpose so it lives for the whole program; convenient for a one-time global handler, a permanent leak for anything created repeatedly.
Key Differences
Section titled “Key Differences”| Concept | TypeScript/JavaScript | Rust + wasm-bindgen |
|---|---|---|
| Passing an object | Reference to a heap object; same identity on both sides | Either a copy into a plain JS object (serde-wasm-bindgen) or an opaque handle to data inside WASM memory (#[wasm_bindgen] struct) |
| 64-bit integers | number (f64) — loses precision past 2^53 | i64/u64 ↔ bigint, always exact |
| Throwing | throw new Error(...) anywhere | only via Err(JsValue) from a function returning Result<_, JsValue> |
| ”any” value | any / unknown | JsValue (must narrow with .as_f64(), js-sys, etc.) |
| Closures / callbacks | First-class functions, GC’d automatically | Closure<...>; you must keep it alive and decide new vs once vs forget |
| Cost of a call | Uniform; objects shared by reference | Primitives ~free; strings/arrays/objects copy bytes across the boundary |
| Type checking of inputs | as Point is a no-op at runtime | serde_wasm_bindgen::from_value actually validates the shape and errors on mismatch |
The deepest difference is the opaque-handle vs plain-object choice for structs. A #[wasm_bindgen] struct keeps its data inside Rust: JavaScript gets a class with methods, the data never leaves WASM memory, and there is no per-access copy — ideal for a long-lived stateful object (a parser, a game world, a database connection). A serde-wasm-bindgen object is the opposite: a one-time deep copy into an ordinary JS object that JavaScript fully owns and can mutate freely — ideal for data you compute once and hand off. Choosing the wrong one is the most common design mistake; the rule of thumb is handle for behavior + long-lived state, serialize for plain data.
Note: Unlike a TypeScript class, an opaque
#[wasm_bindgen]struct is not garbage collected. JavaScript holds a pointer into WASM memory, and you must call.free()on it (the generated class exposes one) when done, or use it via wasm-pack’s optionalWeakRef-based finalization. We return to this in Common Pitfalls.
Common Pitfalls
Section titled “Common Pitfalls”Pitfall 1: Returning a borrowed reference
Section titled “Pitfall 1: Returning a borrowed reference”You cannot return a &str, &[T], or any borrow from a #[wasm_bindgen] function — the boundary has to own what it sends, because the value is copied into JS memory and the Rust stack frame is gone afterward. Return an owned String / Vec<T> instead.
use wasm_bindgen::prelude::*;
#[wasm_bindgen]pub fn label() -> &'static str { // does not compile "hello"}Real compiler output:
error: cannot return a borrowed ref with #[wasm_bindgen] --> src/lib.rs:4:19 |4 | pub fn label() -> &'static str { | ^^^^^^^^^^^^The fix is to return String: pub fn label() -> String { "hello".to_string() }.
Pitfall 2: Exposing an unsupported type
Section titled “Pitfall 2: Exposing an unsupported type”wasm-bindgen only knows the translation table. Hand it a HashMap (or any type that does not implement its IntoWasmAbi/WasmDescribe traits) directly and it cannot generate glue:
use wasm_bindgen::prelude::*;use std::collections::HashMap;
#[wasm_bindgen]pub fn build_map() -> HashMap<String, i32> { // does not compile let mut m = HashMap::new(); m.insert("a".to_string(), 1); m}The real error names the missing trait:
error[E0277]: the trait bound `HashMap<String, i32>: IntoWasmAbi` is not satisfied --> src/lib.rs:4:1 |4 | #[wasm_bindgen] | ^^^^^^^^^^^^^^^ the trait `IntoWasmAbi` is not implemented for `HashMap<String, i32>` | = note: required for `HashMap<String, i32>` to implement `ReturnWasmAbi`The fix: serialize it. Return Result<JsValue, JsValue> and use serde_wasm_bindgen::to_value(&m) — a HashMap serializes to an ES2015 Map by default (or to a plain JS object if you build it with serde_wasm_bindgen::Serializer::new().serialize_maps_as_objects(true), or the preset Serializer::json_compatible(), and call m.serialize(&serializer)).
Pitfall 3: Passing a bare closure where a Closure is required
Section titled “Pitfall 3: Passing a bare closure where a Closure is required”An imported JS function that takes a callback expects a &Closure<...>, not a raw Rust closure. Pass a bare || {} and the types do not line up:
use wasm_bindgen::prelude::*;
#[wasm_bindgen]extern "C" { fn jsTakesCallback(cb: &Closure<dyn FnMut()>);}
#[wasm_bindgen]pub fn try_pass() { jsTakesCallback(|| {}); // does not compile}Real error:
error[E0308]: mismatched types --> src/lib.rs:10:21 |10 | jsTakesCallback(|| {}); | --------------- ^^^^^ expected `&ScopedClosure<'_, dyn FnMut()>`, found closure | | | arguments to this function are incorrect | = note: expected reference `&ScopedClosure<'static, (dyn FnMut() + 'static)>` found closure `{closure@src/lib.rs:10:21: 10:23}`Wrap it: let cb = Closure::<dyn FnMut()>::new(|| {}); jsTakesCallback(&cb); — and remember to keep cb alive (Pitfall 4).
Pitfall 4: A dropped Closure (runtime, not compile-time)
Section titled “Pitfall 4: A dropped Closure (runtime, not compile-time)”This one compiles and then fails at runtime, which makes it nasty. If you create a Closure, register it with setInterval/addEventListener, and let it go out of scope, the closure is freed while JavaScript still holds the function. The next invocation throws — the real message wasm-bindgen emits is closure invoked recursively or after being dropped. The cure is structural, not a compiler hint: store the Closure in a field that outlives the callback (as Ticker does), or call .forget() to intentionally leak it for the program’s lifetime. There is no E-code here because Rust’s type system cannot see across the boundary into JavaScript’s reference; this is a discipline you adopt, the same way you remember to clearInterval in JavaScript.
Pitfall 5: Forgetting that opaque structs are not garbage-collected
Section titled “Pitfall 5: Forgetting that opaque structs are not garbage-collected”const c = new Counter(0) in JavaScript holds a pointer into WASM memory. Dropping the JS variable does not free the Rust value. Long-lived apps that create many short-lived handles and never call .free() will grow WASM memory until the tab dies. Either call the generated c.free() when done, or prefer serde-wasm-bindgen plain objects for throwaway data so the JS garbage collector handles them.
Best Practices
Section titled “Best Practices”- Prefer the richest supported type over
JsValue. A signature offn(p: JsValue)throws away all type information;fn(x: f64, y: f64)or a serde round-trip keeps the.d.tshonest. UseJsValueonly for genuinely dynamic values. - Use
serde-wasm-bindgenfor data,#[wasm_bindgen] structfor behavior. Plain records and DTOs → serialize. Stateful objects with methods and a lifetime → opaque handle. - Return
Result<T, JsValue>for anything fallible so JavaScript gets idiomatictry/catch. Construct ajs_sys::Error(not a bare string) when you want a realErrorwith a stack trace. - Make
Closureownership explicit. Store repeated-call closures in a struct field; useClosure::oncefor fire-once callbacks; reserve.forget()for genuinely program-lifetime handlers and comment why you are leaking. - Let
u64/i64bebigint. Do not cast tof64to “make it anumber” — you reintroduce the precision bugwasm-bindgenwas protecting you from. (See Section 02 types for why JSnumbercannot hold a 64-bit integer exactly.) - Keep boundary crossings coarse. Because strings and objects copy, design APIs that exchange one big batch rather than many tiny calls in a loop (performance.md).
- Enable
console_error_panic_hookin debug builds so a Rust panic shows a readable stack trace in the browser console instead ofunreachable executed.
Real-World Example
Section titled “Real-World Example”A production-flavored module: parse a batch of CSV rows that arrive from JavaScript as a single string, validate each into a typed record with Serde, and return both the parsed records (as a plain JS array) and a progress callback that JavaScript can drive. This exercises every boundary mechanism — &str in, a serde round-trip out, Result/throw, and a Closure.
use wasm_bindgen::prelude::*;use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]pub struct Sale { product: String, units: u32, revenue_cents: u64, // via serde-wasm-bindgen: small values cross as a JS number; large ones need the bigint serializer option}
// Parse "product,units,revenue" lines. On a malformed row, throw with context.#[wasm_bindgen]pub fn parse_sales(csv: &str) -> Result<JsValue, JsValue> { let mut sales: Vec<Sale> = Vec::new();
for (i, line) in csv.lines().enumerate() { let line = line.trim(); if line.is_empty() { continue; } let mut parts = line.split(','); let row = (parts.next(), parts.next(), parts.next());
let sale = match row { (Some(p), Some(u), Some(r)) => Sale { product: p.trim().to_string(), units: u.trim().parse().map_err(|_| { JsValue::from_str(&format!("row {}: bad units `{u}`", i + 1)) })?, revenue_cents: r.trim().parse().map_err(|_| { JsValue::from_str(&format!("row {}: bad revenue `{r}`", i + 1)) })?, }, _ => { return Err(JsValue::from_str(&format!( "row {}: expected 3 columns", i + 1 ))) } }; sales.push(sale); }
// Vec<Sale> -> a real JS array of plain objects. serde_wasm_bindgen::to_value(&sales).map_err(|e| e.into())}
// Sum revenue, reporting progress through a JS callback. The callback is// borrowed for the duration of the call, so no long-lived storage is needed.#[wasm_bindgen]pub fn total_revenue(csv: &str, on_progress: &js_sys::Function) -> Result<f64, JsValue> { let sales: Vec<Sale> = serde_wasm_bindgen::from_value(parse_sales(csv)?)?; let total = sales.len() as f64; let mut sum: u64 = 0;
for (i, sale) in sales.iter().enumerate() { sum += sale.revenue_cents; // Invoke the JS callback with a fraction 0.0..=1.0. let progress = JsValue::from_f64((i + 1) as f64 / total); on_progress.call1(&JsValue::NULL, &progress)?; }
Ok(sum as f64 / 100.0) // cents -> dollars for display}This whole module compiles cleanly against wasm32-unknown-unknown. Note three deliberate choices: revenue_cents is u64 so money is exact in Rust — though, because it goes out through serde_wasm_bindgen::to_value (not a direct #[wasm_bindgen] signature), it lands in JavaScript as a plain number for values within Number.MAX_SAFE_INTEGER; to force bigint you would serialize with Serializer::new().serialize_large_number_types_as_bigints(true). parse_sales returns Result<JsValue, JsValue> so a bad row throws with a row number; and total_revenue takes the callback as a borrowed &js_sys::Function rather than a Closure, because here JavaScript owns the function and Rust only calls it synchronously during the request — no lifetime management required. From TypeScript the generated module is used like any other:
import init, { parse_sales, total_revenue } from "./pkg/geometry";
await init(); // load + instantiate the .wasm (see ./wasm-pack.md and ./rust-from-js.md)
const csv = "widget, 3, 1999\ngadget, 1, 4950";const sales = parse_sales(csv) as { product: string; units: number; revenue_cents: number }[];console.log(sales); // [{ product: 'widget', units: 3, revenue_cents: 1999 }, ...]
const dollars = total_revenue(csv, (p: number) => console.log(`${Math.round(p * 100)}%`));console.log(`total: $${dollars}`); // 50%, 100%, total: $69.49Further Reading
Section titled “Further Reading”- The
wasm-bindgenGuide — the official, authoritative reference for every attribute and supported type. - Supported Rust types reference — the full translation table.
Closuredocumentation —newvsoncevsforget, and lifetime rules.serde-wasm-bindgenon docs.rs —to_value/from_value, theSerializeroptions.js-sysdocumentation —js_sys::Function,js_sys::Error, and the rest of the JS standard library from Rust.- Section cross-links: wasm-intro.md · wasm-pack.md · first-wasm.md · js-interop.md · rust-from-js.md · web-apis.md · performance.md
- Foundations: Section 15 — Serde basics (the derive model reused here) · Section 02 — Types (why
numberis f64) · the low-level FFI cousin of this boundary.
Exercises
Section titled “Exercises”Exercise 1: Owned return types
Section titled “Exercise 1: Owned return types”Difficulty: Beginner
Objective: Internalize why the boundary must own what it returns.
Instructions: Write a #[wasm_bindgen] function initials(full_name: &str) -> String that returns the uppercase first letter of each whitespace-separated word (so "ada lovelace" → "AL"). Confirm it compiles against wasm32-unknown-unknown. Then change the return type to &str and observe the compiler error; explain in one sentence why owning the result is mandatory here.
Solution
use wasm_bindgen::prelude::*;
#[wasm_bindgen]pub fn initials(full_name: &str) -> String { full_name .split_whitespace() .filter_map(|word| word.chars().next()) .flat_map(|c| c.to_uppercase()) .collect()}This compiles cleanly. Returning &str instead fails with error: cannot return a borrowed ref with #[wasm_bindgen] because the function builds a new string that no longer exists once the call returns — the boundary copies the owned String’s bytes into JS memory, and there is nothing for a borrow to point at afterward.
Exercise 2: serde round-trip with validation
Section titled “Exercise 2: serde round-trip with validation”Difficulty: Intermediate
Objective: Pass a structured object across the boundary and validate it, contrasting with TypeScript’s unchecked as.
Instructions: Define a #[derive(Serialize, Deserialize)] struct User { name: String, age: u32 }. Write fn make_adult(user: JsValue) -> Result<JsValue, JsValue> that deserializes the incoming object, returns an error (thrown to JS) if age < 18, otherwise bumps a new is_adult: true-style flag by returning a new struct Profile { name: String, age: u32, adult: bool } serialized back to a JsValue. Compile-verify it.
Solution
use wasm_bindgen::prelude::*;use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]pub struct User { name: String, age: u32,}
#[derive(Serialize, Deserialize)]pub struct Profile { name: String, age: u32, adult: bool,}
#[wasm_bindgen]pub fn make_adult(user: JsValue) -> Result<JsValue, JsValue> { let user: User = serde_wasm_bindgen::from_value(user)?; if user.age < 18 { return Err(JsValue::from_str(&format!( "{} is under 18 (age {})", user.name, user.age ))); } let profile = Profile { name: user.name, age: user.age, adult: true, }; serde_wasm_bindgen::to_value(&profile).map_err(|e| e.into())}serde_wasm_bindgen::from_value actually checks that the incoming object has a string name and a numeric age; a missing or wrong-typed field returns Err (thrown to JavaScript), unlike TypeScript’s user as User, which is erased at runtime and would let a malformed object through.
Exercise 3: A self-storing event closure
Section titled “Exercise 3: A self-storing event closure”Difficulty: Advanced
Objective: Hand a long-lived Rust closure to JavaScript without it being dropped, and free it deliberately.
Instructions: Import the JS functions addClick(cb: &Closure<dyn FnMut()>) and removeClick(cb: &Closure<dyn FnMut()>). Build a #[wasm_bindgen] struct ClickCounter whose constructor registers a click handler that increments an internal count (use Rc<Cell<u32>> so the closure and the struct can share it), stores the Closure in a field, and exposes a count(&self) -> u32 getter plus a stop(self) method that unregisters the handler. Explain why the Closure must be stored. Compile-verify.
Solution
use wasm_bindgen::prelude::*;use std::cell::Cell;use std::rc::Rc;
#[wasm_bindgen]extern "C" { fn addClick(cb: &Closure<dyn FnMut()>); fn removeClick(cb: &Closure<dyn FnMut()>);}
#[wasm_bindgen]pub struct ClickCounter { count: Rc<Cell<u32>>, handler: Closure<dyn FnMut()>,}
#[wasm_bindgen]impl ClickCounter { #[wasm_bindgen(constructor)] pub fn new() -> ClickCounter { let count = Rc::new(Cell::new(0u32)); let count_for_cb = Rc::clone(&count); let handler = Closure::new(move || { count_for_cb.set(count_for_cb.get() + 1); }); addClick(&handler); ClickCounter { count, handler } }
#[wasm_bindgen(getter)] pub fn count(&self) -> u32 { self.count.get() }
pub fn stop(self) { removeClick(&self.handler); // `self` (and its Closure) is dropped here, freeing the JS function. }}The Closure is stored in handler because JavaScript keeps calling it on every click. If it were a local variable in new, it would be dropped when the constructor returned, and the next click would throw closure invoked recursively or after being dropped. Rc<Cell<u32>> lets the closure and the struct share one mutable counter (WASM is single-threaded, so Rc/Cell are appropriate — no Arc/Mutex needed). stop(self) takes ownership, removes the listener, and lets the Closure drop cleanly.