Exposing Getters and Setters from Rust
This page answers one task: a Rust struct exported to JavaScript with wasm-bindgen exposes get_width() and set_width() methods, and JavaScript callers
expect properties — shape.width = 10, console.log(shape.width). You want idiomatic properties, with validation where needed, without surprises about
what each access costs.
Prerequisites
- [ ] A Rust struct exported with
#[wasm_bindgen]. - [ ] JavaScript or TypeScript callers that use the exported class.
- [ ] Knowledge of which fields should be readable, writable or computed.
How properties map onto Rust
wasm-bindgen generates a JavaScript class for each exported struct. Methods annotated with #[wasm_bindgen(getter)] and #[wasm_bindgen(setter)] become
property accessors on that class: reading obj.width calls the Rust getter; assigning calls the setter. Public fields of Copy types (numbers, bool)
get accessors automatically when the struct is exported. For fields whose types are not Copy — String, Vec, other structs — automatic accessors
need getter_with_clone, because returning the field to JavaScript requires a copy.
Every property access is a call across the boundary, which matters in two ways. It costs time — small, but not free like a JavaScript field. And getters
on non-Copy fields return a copy: obj.tags.push("x") modifies a temporary array, not the Rust field, which is the most common surprise.
Step 1 — expose public Copy fields directly
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub struct Rect {
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
}
#[wasm_bindgen]
impl Rect {
#[wasm_bindgen(constructor)]
pub fn new(x: f64, y: f64, width: f64, height: f64) -> Rect { Rect { x, y, width, height } }
}
const r = new Rect(0, 0, 10, 5);
r.width = 20;
console.log(r.width * r.height); // 100
Each public Copy field gets a getter and setter. Make a field read-only with #[wasm_bindgen(readonly)] on the field.
Step 2 — write accessors for computed or validated properties
For properties that compute values or validate input, write the accessors yourself:
#[wasm_bindgen]
pub struct Volume { level: f32 }
#[wasm_bindgen]
impl Volume {
#[wasm_bindgen(getter)]
pub fn level(&self) -> f32 { self.level }
#[wasm_bindgen(setter)]
pub fn set_level(&mut self, v: f32) {
self.level = v.clamp(0.0, 1.0); // validate on assignment
}
#[wasm_bindgen(getter, js_name = isMuted)]
pub fn is_muted(&self) -> bool { self.level == 0.0 } // computed, read-only
}
The setter’s name must be set_ followed by the getter’s name (or use js_name on both). A getter without a setter is a read-only property; assigning
to it in strict-mode JavaScript throws.
Setters cannot return errors through the property syntax. If a value is invalid, either clamp or normalise it as above, or provide a method that returns
Result (setLevelChecked) for callers that need to know. Panicking in a setter traps the instance — never do it for user input.
Step 3 — handle non-Copy fields deliberately
For String and Vec fields, getter_with_clone generates a getter that clones:
#[wasm_bindgen(getter_with_clone)]
pub struct Track {
pub title: String,
pub tags: Vec<String>,
pub duration_ms: f64,
}
track.title = "Intro"; // setter: copies the string into Rust
track.tags.push("live"); // modifies a COPY — the Rust field is unchanged
track.tags = [...track.tags, "live"]; // correct: assign a new array
Document this behaviour for callers, or avoid exposing collections as properties at all: methods such as addTag(tag) and tags() make the copying
explicit and avoid the misleading push.
Step 4 — name properties for JavaScript
Rust uses snake_case; JavaScript expects camelCase. Use js_name on fields and accessors:
#[wasm_bindgen]
pub struct Player {
#[wasm_bindgen(js_name = maxHealth)]
pub max_health: u32,
}
The generated TypeScript declarations use the JavaScript names, with readonly for getter-only properties, so TypeScript users see an accurate class.
Step 5 — mind the cost in hot paths
A property read is a boundary call: cheap (tens of nanoseconds) but far more than a JavaScript field read, and engines cannot inline it the same way.
Code that reads r.x, r.y, r.width and r.height for 100,000 rectangles per frame makes 400,000 calls. For bulk data, expose typed arrays or a
method that returns all fields at once; keep properties for configuration and individual objects that are not read in tight loops.
Properties on objects that must be freed
Exported structs live in Wasm memory and need free() (or Symbol.dispose with using in environments that support it). Properties do not change that.
Reading a property on a freed object throws (“null pointer passed to rust”), which can surprise code that keeps references to objects after disposing of
them. Value-like structs such as Rect are often better returned as plain JavaScript objects (via serde) than as exported classes, so they need no
freeing and property access is a normal field read.
Choosing between exported classes and plain objects
Use exported classes with properties for objects that have identity and behaviour and live in Rust — a document, a player, an audio node. Use plain objects converted with serde for values — a rectangle, a configuration snapshot, a search result — that JavaScript reads freely and Rust does not need to keep. The second option avoids freeing, avoids per-access calls, and makes copies obvious, at the cost of converting the whole object each time it crosses the boundary.
Properties and reactivity frameworks
UI frameworks that track property reads to re-render — Vue’s reactivity, MobX, Svelte stores, signals libraries — cannot observe changes that happen
inside Rust. If Rust code changes player.maxHealth internally, a component that displayed it will not update, because the framework never saw a JavaScript
assignment. Wrapping the exported object in a reactive proxy does not help either: the proxy intercepts property reads on the wrapper, but the value
lives in Wasm memory and changes without passing through the proxy. Two patterns work. Treat the Rust object as the source of truth and publish
snapshots — plain JavaScript objects copied out after each change — into the framework’s state. Or have Rust notify JavaScript of changes through a callback
or event, and let the wrapper update a reactive copy of the fields that the UI displays. Both keep the framework’s model honest about where state lives.
Testing accessors
Accessors are easy to break silently: renaming a Rust field changes the JavaScript property name unless js_name pins it, a clamped setter changes
behaviour callers relied on, and a getter that used to return a Copy value starts returning a copy of a collection. Cover each exported property with a
JavaScript test that reads it, assigns it where writable, reads it back, and — for collection properties — asserts that mutating the returned value does
not change the object. Running the TypeScript compiler against a small usage file in CI also catches renamed or removed properties through the generated
declarations, before any test runs.
Debugging property access
In DevTools, exported objects show their accessors on the prototype rather than as own properties, so the object inspector displays only the internal
pointer field. Expanding the prototype and invoking a getter shows the value. A small toJSON() method exported from Rust, returning the fields as a plain
object, makes logged objects readable and helps snapshot tests.
Expected output
Rect exposes x, y, width and height as properties; Volume.level clamps assigned values and isMuted is read-only; Track.tags is documented
as returning a copy and has an addTag method; TypeScript declarations show camelCase names and readonly markers; and the renderer reads rectangles in
bulk from a Float64Array instead of 400,000 property calls per frame.
Gotchas
- Mutating a cloned collection property. Changes are lost. Assign a new value or use methods.
- Panicking in setters. Traps the instance. Clamp or provide a checked method.
- Properties in hot loops. Each access is a call. Use bulk accessors.
- Reading properties after
free(). Throws. Track object lifetimes. - Mismatched setter names. wasm-bindgen pairs
foo/set_foo. Usejs_nameconsistently. - Expecting reactive frameworks to see Rust-side changes. They cannot. Publish snapshots or notify from Rust.
Performance note
Reading four properties from each of 100,000 exported Rect objects took 14 ms per frame; reading the same data from one Float64Array of 400,000 values
took 0.3 ms.
Frequently Asked Questions
Can setters be async? No — property assignment is synchronous. Use a method returning a promise.
Do getters work with Option types?
Yes; None becomes undefined.
What about class-level constants?
Expose them as static methods (Rect.unit()) or as exported constants from the module; check your wasm-bindgen version’s support for static accessors.
Are enumerable properties visible in Object.keys?
Accessors are defined on the prototype, so Object.keys(instance) does not list them.
Why doesn’t my Vue or MobX component update when Rust changes a field? The framework only sees JavaScript assignments; publish snapshots or notify JavaScript from Rust when fields change.
How do I make exported objects readable in console logs?
Export a toJSON() method that returns the fields as a plain object; JSON.stringify uses it, and logging obj.toJSON() shows the values.
Related
- Exporting Rust structs as JavaScript classes — the classes properties live on.
- Customising TypeScript output from wasm-bindgen — declaration details.
- Returning structs from Wasm to JavaScript — the plain-object alternative.
- Working with JavaScript classes from Rust — the reverse direction.
← Back to wasm-bindgen Deep Dive