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.

Methods versus properties on an exported struct Explicit get and set methods make the boundary call visible and are the default without annotations. Getter and setter attributes give idiomatic properties, which read naturally but hide that each access is a call and that non-Copy values are returned as copies. get_width() / set_width(v) call is visible no annotation needed unidiomatic for JS users explicit obj.width (getter/setter) idiomatic JS properties each access is a call non-Copy values are copies preferred API

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.

Why push on a cloned property does nothing Reading track.tags calls the Rust getter, which clones the vector into a new JavaScript array. push modifies that array. The Rust field is untouched, and the next read returns a fresh copy without the new tag. read track.tags getter call Rust clones Vec new JS array push("live") on the copy Rust field unchanged still old tags next read fresh copy, no tag

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. Use js_name consistently.
  • 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.

Reading 100,000 rectangles per frame Milliseconds per frame to read x, y, width and height for 100,000 rectangles through property getters on exported objects and from a single Float64Array exposed by the module. ms per frame property getters 14 ms one Float64Array 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.

← Back to wasm-bindgen Deep Dive