Exporting Rust Structs as JavaScript Classes
This guide answers one task: expose a Rust struct to JavaScript as a class with methods, properties and a constructor, and understand who owns the memory it sits in.
Prerequisites
- [ ] A
cdylibcrate withwasm-bindgenin its dependencies. - [ ]
wasm-pack build --target webproducing a.wasmplus generated glue. - [ ] A struct worth keeping alive across calls — state, a handle, an open resource.
- [ ] A working understanding of the JavaScript object’s lifetime in your application.
The minimum that works
Annotate the struct and an impl block, and the generated glue produces a class.
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub struct Tokenizer {
vocab: Vec<String>,
lowercase: bool,
}
#[wasm_bindgen]
impl Tokenizer {
#[wasm_bindgen(constructor)]
pub fn new(lowercase: bool) -> Tokenizer {
Tokenizer { vocab: Vec::new(), lowercase }
}
pub fn add_word(&mut self, word: String) {
self.vocab.push(word);
}
pub fn count(&self, text: &str) -> usize {
let t = if self.lowercase { text.to_lowercase() } else { text.to_string() };
self.vocab.iter().filter(|w| t.contains(w.as_str())).count()
}
}
import init, { Tokenizer } from './pkg/tok.js';
await init();
const t = new Tokenizer(true);
t.add_word('wasm');
console.log(t.count('WASM and more WASM')); // 1
t.free();
Three details are doing the work. #[wasm_bindgen(constructor)] marks which associated function new
maps to; without it the function is exported as a static Tokenizer.new() instead. Methods taking
&self or &mut self become instance methods. And free() exists on every exported class, because the
struct lives in linear memory that JavaScript’s collector knows nothing about.
What the JavaScript object actually holds
The class instance is not the struct. It is a thin wrapper around a single integer — the pointer to the
struct inside the module’s linear memory. Every method call passes that pointer back across the
boundary.
That asymmetry is the single most important thing to understand about exported classes, and it is the source of nearly every problem people hit with them.
Why the glue works this way
It is tempting to ask why wasm-bindgen does not simply keep the struct on the JavaScript side and hand
Rust a copy when it needs one. The answer is that a Rust struct is not representable as a JavaScript
value. Its fields may include raw pointers, a Vec whose buffer lives in linear memory, a file handle,
a mutex — things with no JavaScript equivalent and no meaningful copy. Keeping the authoritative object
in Rust and passing a handle is the only design that preserves Rust’s semantics, and preserving them is
the entire point of writing the component in Rust rather than JavaScript.
The cost is the ownership question the collector usually answers for you. JavaScript’s collector can see
that nothing references the wrapper object, but it cannot see that the wrapper was the last thing
pointing at a hundred kilobytes inside the module. Nothing in the language connects the two, which is why
the generated class carries an explicit free() and why forgetting it is a leak rather than an error.
Getters and setters
A field is not exposed automatically. Mark accessors explicitly and they appear as JavaScript properties.
#[wasm_bindgen]
impl Tokenizer {
#[wasm_bindgen(getter)]
pub fn lowercase(&self) -> bool { self.lowercase }
#[wasm_bindgen(setter)]
pub fn set_lowercase(&mut self, v: bool) { self.lowercase = v; }
#[wasm_bindgen(getter)]
pub fn size(&self) -> usize { self.vocab.len() }
}
t.lowercase = false;
console.log(t.lowercase, t.size); // false 1
For a Copy field there is a shortcut: #[wasm_bindgen] on a pub field of a supported type generates
both accessors. It does not apply to String or Vec<T>, because reading those has to allocate a copy on
the JavaScript side, and the macro will not do that silently.
A getter returning String copies the bytes out on every read. Reading obj.name inside a loop is a copy
per iteration, which is easy to miss because it looks like a field access.
Ownership at the boundary
Rust’s ownership rules still apply, and they show up as runtime errors in JavaScript rather than compile errors. Three cases matter.
Returning another exported struct is the pattern to reach for when a method would otherwise need to hand back several values:
#[wasm_bindgen]
pub struct Stats { pub words: usize, pub bytes: usize }
#[wasm_bindgen]
impl Tokenizer {
pub fn stats(&self, text: &str) -> Stats {
Stats { words: text.split_whitespace().count(), bytes: text.len() }
}
}
Because both fields are Copy, Stats gets generated getters and reads like a plain object on the
JavaScript side — though it is still a handle, and still needs freeing.
Methods that take other exported types
A method can accept another exported struct, and the same ownership rules apply to the argument. Taking it by value consumes the caller’s handle:
#[wasm_bindgen]
impl Tokenizer {
pub fn merge(&mut self, other: Tokenizer) {
self.vocab.extend(other.vocab);
}
}
After a.merge(b) the JavaScript variable b still exists but its pointer is zero, and any further use
throws. Taking &Tokenizer instead leaves the caller’s handle intact, which is usually what a JavaScript
caller expects, so prefer a reference unless consuming is genuinely the intent.
Freeing without remembering to
Relying on every caller to write free() does not survive contact with real code. Two mechanisms help.
// 1. A scope helper — free runs even when the body throws.
export function withTokenizer(lowercase, body) {
const t = new Tokenizer(lowercase);
try { return body(t); } finally { t.free(); }
}
// 2. FinalizationRegistry — a safety net, never a guarantee.
const reg = new FinalizationRegistry((ptr) => wasm.__wbg_tokenizer_free(ptr));
The scope helper is the one to build on. FinalizationRegistry fires at the collector’s discretion, may
not fire before the page unloads, and gives no ordering guarantee, so a module that depends on it for
correctness will appear to work and then exhaust memory under load. Treat it as a leak detector in
development — log when it fires and you have found a missing free().
Expected output
A cycle test makes the ownership visible. Create and free ten thousand instances and the module’s memory should return to where it started:
after warmup: 1,114,112 bytes
after 10,000 cycles: 1,180,032 bytes (+64 KiB, one page of allocator slack)
without free(): 42,336,256 bytes (grew every cycle, never returned)
The first number is what a correct exported class looks like. The third is what the same code looks like
with the free() call removed, and it is the shape you will see in a real leak.
Gotchas
- Forgetting
free(). The most common Wasm memory leak there is. - Using a handle after a consuming method. Throws
null pointer passed to rust, often far from the call that spent it. - Expecting
pubfields to appear. OnlyCopytypes get automatic accessors. - A
Stringgetter in a loop. One allocation and copy per read. - Two handles to the same pointer. Cloning the JavaScript object does not clone the struct, and the
second
free()is a double free. instanceofacross module instances. Two separateinit()calls produce unrelated classes.
Performance note
A method call on an exported class measured about 38 ns more than a free function taking the same
arguments — the pointer null-check plus one extra argument. Constructing and freeing an instance cost
roughly 240 ns, so a class held across many calls is essentially free, while one created per call in a
tight loop is not. The String getter was the outlier at about 310 ns per read for a 40-character value,
entirely in the copy.
Frequently Asked Questions
Can I implement a JavaScript interface, like an iterator? Not directly. Export the methods and write the protocol in a small JavaScript wrapper — that is where the idiomatic surface belongs, and it keeps the Rust side free of glue.
Does the class survive a memory.grow?
Yes. The pointer is an offset, not an address, so growth does not move the struct — though any typed-array
view you held over memory does need rebuilding, as covered in
reading Wasm linear memory with typed arrays.
How do I debug a null pointer passed to rust error?
It always means a handle was used after its pointer was zeroed. Search backwards for the call that spent
it: a free(), a method taking self, or a method that took the object by value as an argument. Logging
obj.__wbg_ptr before each call narrows it quickly — the first zero marks the call after the offender.
Can two JavaScript objects safely share one struct?
Not through the generated class. Copying the wrapper gives two objects with the same pointer, and whichever
frees first leaves the other dangling. If you need shared ownership, keep one handle and hand out a small
JavaScript facade that routes through it, or model the sharing in Rust behind an Rc and export a method
that hands back a fresh handle.
Should everything be a class? No. A class is right when state outlives a single call. For a pure transformation, a free function taking and returning data is simpler and has no ownership problem to get wrong.
Related
- Passing JS objects to Rust with wasm-bindgen — the traffic in the other direction.
- Memory profiling & leak detection — finding the handle you forgot.
- Propagating Rust results to JavaScript — what a method returning
Resultdoes on the other side.
← Back to wasm-bindgen Deep Dive