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 cdylib crate with wasm-bindgen in its dependencies.
  • [ ] wasm-pack build --target web producing a .wasm plus 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.

The class is a handle, not the object The JavaScript instance stores a single numeric pointer. The struct's fields live in the module's linear memory, outside the reach of JavaScript's garbage collector, which is why an explicit free call is needed. JavaScript heap Tokenizer instance __wbg_ptr = 1114128 collected by the JS collector linear memory the struct's bytes vocab pointer + length + flag freed only by an explicit call one integer Dropping the JavaScript reference reclaims the wrapper — roughly forty bytes — and leaves the struct behind. That is the leak people report as "my Wasm memory only grows": the handle went away and the bytes did not. Calling free() runs the struct's Drop implementation and returns the allocation to the module's allocator.

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.

What each receiver does to the handle A borrowing method leaves the handle usable. A consuming method invalidates it, and a later call throws a null pointer error. An explicit free does the same deliberately. fn count(&self) borrows handle stays valid fn into_parts(self) consumes pointer set to zero obj.free() drops deliberately pointer set to zero a later call throws: null pointer passed to rust A consuming method is not a mistake — it is the right signature for a builder that turns into something else. What matters is that the JavaScript side knows the handle is spent, which the generated glue does not say. Name such methods so the caller can tell: into_document(), finish(), take_buffer().

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.

Ten thousand cycles, with and without free With an explicit free, module memory returns to its starting point apart from allocator slack. Without it, memory climbs with every cycle and never comes back down. 40 MB 1 MB with free() without free() create / destroy cycles → A straight climb with no plateau is the signature: a leaked handle never gets collected, so nothing ever flattens the line.

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 pub fields to appear. Only Copy types get automatic accessors.
  • A String getter 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.
  • instanceof across module instances. Two separate init() 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.

← Back to wasm-bindgen Deep Dive