Working with JavaScript Classes from Rust

This page answers one task: Rust code in a WebAssembly module must create and use instances of JavaScript classes — your application’s own classes, a library’s classes, or built-in ones not covered by js-sys and web-sys — calling constructors, methods and properties with type checking, and recognising instances passed in from JavaScript.

Prerequisites

  • [ ] A Rust crate using wasm-bindgen.
  • [ ] The JavaScript class available as an ES module export, or as a global.
  • [ ] Knowledge of the class’s constructor, methods and properties.

How wasm-bindgen imports classes

wasm-bindgen describes JavaScript APIs to Rust with extern "C" blocks annotated with #[wasm_bindgen]. A type declaration inside such a block becomes a Rust type representing instances of a JavaScript class — an opaque handle, internally a JsValue. Functions in the block annotated with constructor, method, static_method_of, getter and setter become Rust methods that call the corresponding JavaScript operations through generated glue. module = "..." tells wasm-bindgen which ES module to import the class from; js_namespace handles globals such as Intl.Collator.

Rust gets static types for the parts of the class it declares; nothing checks at compile time that the declarations match the real class, so mistakes show up as runtime TypeErrors. Declare only what Rust uses, and keep the declarations next to tests that exercise them.

Calling a JavaScript class from Rust A wasm-bindgen extern block declares the class type, its constructor and methods, with the module it comes from. Rust calls Rust-looking methods. The generated glue imports the class from the module and performs new, method calls and property access in JavaScript, returning results converted to Rust types. extern block: type Chart module = "./chart.js" Chart::new(el, opts) Rust call glue: new Chart(...) imported class chart.add_ series(...) method via glue results to Rust converted types

Step 1 — declare the class

Given a JavaScript class in your application:

// chart.js
export class Chart {
  constructor(element, options) { /* ... */ }
  addSeries(name, values) { /* ... */ return this.series.length - 1; }
  get seriesCount() { return this.series.length; }
  set title(t) { this._title = t; }
  static fromConfig(config) { return new Chart(document.body, config); }
}

Declare it for Rust:

use wasm_bindgen::prelude::*;

#[wasm_bindgen(module = "/js/chart.js")]
extern "C" {
    pub type Chart;

    #[wasm_bindgen(constructor)]
    pub fn new(element: &web_sys::Element, options: &JsValue) -> Chart;

    #[wasm_bindgen(method, js_name = addSeries)]
    pub fn add_series(this: &Chart, name: &str, values: &[f64]) -> u32;

    #[wasm_bindgen(method, getter, js_name = seriesCount)]
    pub fn series_count(this: &Chart) -> u32;

    #[wasm_bindgen(method, setter)]
    pub fn set_title(this: &Chart, title: &str);

    #[wasm_bindgen(static_method_of = Chart, js_name = fromConfig)]
    pub fn from_config(config: &JsValue) -> Chart;
}

module = "/js/chart.js" is resolved relative to the crate root and copied into the output; for npm packages use the package name (module = "chart-lib") and let the bundler resolve it.

Step 2 — create and use instances

#[wasm_bindgen]
pub fn draw_report(el: &web_sys::Element, data: &Report) {
    let chart = Chart::new(el, &serde_wasm_bindgen::to_value(&data.options).unwrap());
    for s in &data.series {
        chart.add_series(&s.name, &s.values);
    }
    chart.set_title(&format!("{} series", chart.series_count()));
}

Each call crosses the boundary; slices such as &[f64] are copied into new Float64Arrays. The Chart value is dropped at the end of the function, releasing Rust’s reference — the JavaScript object lives on as long as JavaScript references it (here, through the DOM).

Step 3 — check instances with instanceof

When JavaScript passes values into Rust as JsValue, use JsCast to check and convert. dyn_into performs an instanceof check against the imported class:

use wasm_bindgen::JsCast;

#[wasm_bindgen]
pub fn attach(target: JsValue) -> Result<(), JsValue> {
    let chart: Chart = target.dyn_into().map_err(|_| JsValue::from_str("expected a Chart"))?;
    chart.set_title("attached");
    Ok(())
}

dyn_into fails for objects from another realm (an iframe) or for duck-typed objects that are not real instances; if you need structural checks, declare #[wasm_bindgen(is_type_of = ...)] with a custom predicate, or use unchecked_into when you trust the caller.

dyn_into versus unchecked_into dyn_into checks with instanceof and returns an error for values that are not instances, at the cost of one check. unchecked_into converts without checking, which is free but leads to TypeErrors later if the value is wrong. dyn_into::<Chart>() instanceof check Err for wrong values fails across realms untrusted input unchecked_into::<Chart>() no check, no cost wrong values fail later for values you created trusted values

Step 4 — extend behaviour without subclassing

Rust cannot subclass a JavaScript class directly — wasm-bindgen has no way to declare a Rust type that extends an imported class and overrides methods. Two patterns cover most needs. Composition: wrap the JavaScript object in a Rust struct and add behaviour around it. Callbacks: if the class supports hooks (event listeners, strategy objects, render callbacks), pass Rust closures as those hooks. When subclassing is truly required — a framework that only accepts subclasses, such as custom elements — write the subclass in JavaScript and have its methods delegate to Rust exports:

import { render_widget } from "./pkg/widgets.js";
class WasmWidget extends HTMLElement {
  connectedCallback() { render_widget(this); }
}
customElements.define("wasm-widget", WasmWidget);

#[wasm_bindgen(extends = ...)] exists for declaring an imported type’s superclass, so Rust can use parent-class methods on it (as web-sys does for HtmlElement extends Element); it does not create subclasses.

Step 5 — keep declarations correct

Because declarations are unchecked, test them: a wasm-bindgen-test that constructs the class, calls each declared method and reads each property catches renamed methods and changed signatures. For classes from npm libraries with TypeScript types, compare declarations against the .d.ts when upgrading the library. Return types deserve particular care: declaring a method as returning u32 when it returns undefined produces 0 silently; declaring it Option<u32> makes the absence visible.

Properties, statics and globals

Getters and setters map to getter/setter methods; use js_name when JavaScript uses camelCase. Static properties use #[wasm_bindgen(static_method_of = Chart, getter)]. Global classes — those not exported from a module, such as Intl.NumberFormat or a library loaded via a script tag — use js_namespace instead of module:

#[wasm_bindgen]
extern "C" {
    #[wasm_bindgen(js_namespace = Intl, js_name = NumberFormat)]
    pub type NumberFormat;
    #[wasm_bindgen(constructor, js_namespace = Intl, js_class = "NumberFormat")]
    pub fn new(locale: &str, options: &JsValue) -> NumberFormat;
    #[wasm_bindgen(method)]
    pub fn format(this: &NumberFormat, n: f64) -> String;
}

Performance considerations

Each method call is a boundary crossing with argument conversion. Calling a JavaScript method per element in a large loop — chart.add_point(x, y) a million times — costs far more than passing arrays once. Design the JavaScript class or a thin adapter to accept bulk data (addSeries(name, values) rather than addPoint), and cache instances in Rust rather than re-creating them per call.

Errors thrown by JavaScript methods

A JavaScript method can throw, and by default a wasm-bindgen import declared with a plain return type assumes it does not: if it throws, the exception unwinds through the Wasm frames and surfaces at the outermost JavaScript caller, skipping any Rust cleanup in between and potentially leaving Rust state half-updated. Declare methods that may throw with catch, which changes the Rust signature to return Result<T, JsValue>:

#[wasm_bindgen(method, catch, js_name = addSeries)]
pub fn try_add_series(this: &Chart, name: &str, values: &[f64]) -> Result<u32, JsValue>;

Rust then handles the error explicitly — retrying, mapping it to a Rust error, or propagating it with ? from an export that itself returns Result. Use catch for anything that touches user input, the network, storage, or third-party code; for simple getters that cannot fail, the plain form is fine and slightly cheaper.

Keeping JavaScript objects alive from Rust

A Rust value of an imported class type holds a reference that keeps the JavaScript object alive until the Rust value is dropped. Storing such values in long-lived Rust structures — a global registry of charts, a cache of formatters — keeps the JavaScript objects (and whatever they reference, such as DOM nodes) alive indefinitely. That is often intended, but it is also a common source of leaks after the UI has removed the corresponding element. Pair every long-lived reference with an explicit removal path, and when Rust only needs to refer to an object occasionally, consider keeping it in a JavaScript-side map keyed by an ID and passing the ID to Rust instead.

Expected output

Rust creates Chart instances with Chart::new, adds series with typed slices, sets the title through a setter and reads seriesCount through a getter; attach rejects non-Chart values with a clear error; a custom element subclass written in JavaScript delegates rendering to Rust; and a test exercises every declared method so a renamed JavaScript method fails CI.

Gotchas

  • Declarations drifting from the class. Runtime TypeErrors. Test every declared member.
  • dyn_into across iframes. instanceof fails across realms. Use custom type checks.
  • Expecting Rust subclasses. Not supported. Compose, use callbacks, or subclass in JavaScript.
  • Wrong return types. undefined becomes 0 silently. Use Option where values can be missing.
  • Per-element method calls. Expensive. Pass arrays.
  • Imports that can throw declared without catch. Exceptions skip Rust cleanup. Return Result for fallible calls.

Performance note

Adding 100,000 points with one addPoint call each took 38 ms from Rust; one addSeries call with a Float64Array took 0.6 ms.

Adding 100,000 points to a JavaScript chart from Rust Milliseconds to add 100,000 points to a chart instance from Rust with one method call per point and with a single call passing all values as an array. ms total one addPoint per value 38 ms one addSeries with array 0.6 ms

Frequently Asked Questions

Can Rust export a class that JavaScript subclasses? JavaScript can subclass an exported class syntactically, but overridden methods are not called by Rust code; prefer composition.

How do I call an async method? Declare it as returning js_sys::Promise and await it with JsFuture.

Can I import a default-exported class? Use js_name = default on the type and constructor declarations.

Does web-sys use the same mechanism? Yes — web-sys is generated extern blocks for Web IDL interfaces.

What happens if an imported JavaScript method throws? Without catch, the exception unwinds through Wasm to the outer caller; declare the import with catch to get a Result in Rust.

Does storing a class instance in Rust keep it alive? Yes, until the Rust value is dropped; long-lived Rust references keep the JavaScript object and anything it references alive.

← Back to wasm-bindgen Deep Dive