Importing JavaScript Modules into Rust
This page answers one task: Rust code compiled with wasm-bindgen needs to call JavaScript that is not a Web API — a helper you wrote, a function from an npm package, a class with its own state — and the generated package must ship and resolve that JavaScript correctly.
Prerequisites
- [ ] A crate built with wasm-bindgen and wasm-pack (or
wasm-bindgen-cli). - [ ] JavaScript helpers as ES modules.
- [ ] Familiarity with calling Web APIs from Rust with wasm-bindgen.
Imports beyond web-sys
web-sys and js-sys cover the browser’s built-in APIs and JavaScript’s standard library. Real projects also need their own JavaScript: a helper that
wraps a third-party library, a function that is simpler to write in JavaScript, a class that holds DOM state. wasm-bindgen imports these the same way it
imports built-ins — an extern "C" block of declarations — with one extra attribute saying which module they come from.
How the module is located depends on the attribute. module = "/path" (leading slash) refers to a file in your crate, which wasm-bindgen copies into
the output as a snippet and imports by relative path. module = "package-name" (no slash) is emitted as a bare import that the bundler resolves from
node_modules. inline_js embeds a string of JavaScript as a snippet. raw_module emits the specifier exactly as written, for advanced cases.
Step 1 — import a local helper file
Write the helper as an ES module inside the crate:
// js/format.js (path relative to the crate root)
export function formatBytes(n) {
const units = ["B", "KB", "MB", "GB"];
let i = 0;
while (n >= 1024 && i < units.length - 1) { n /= 1024; i++; }
return `${n.toFixed(i ? 1 : 0)} ${units[i]}`;
}
Declare it in Rust with a path starting at the crate root:
#[wasm_bindgen(module = "/js/format.js")]
extern "C" {
#[wasm_bindgen(js_name = formatBytes)]
fn format_bytes(n: f64) -> String;
}
#[wasm_bindgen]
pub fn describe(len: usize) -> String {
format!("Payload: {}", format_bytes(len as f64))
}
On build, wasm-bindgen copies js/format.js into pkg/snippets/<crate-name>-<hash>/js/format.js and the generated glue imports it from there. The
package is self-contained; consumers need nothing extra. Because the copy happens at build time, editing the helper requires a rebuild of the
package before the change is visible, which is worth wiring into the development watcher.
Step 2 — import from an npm package
For a dependency of the JavaScript application, use the bare package name:
#[wasm_bindgen(module = "idb-keyval")]
extern "C" {
#[wasm_bindgen(catch)]
async fn get(key: &str) -> Result<JsValue, JsValue>;
#[wasm_bindgen(catch)]
async fn set(key: &str, value: JsValue) -> Result<JsValue, JsValue>;
}
The glue then contains import { get, set } from "idb-keyval", which only a bundler (or an import map) can resolve. Add the package as a dependency of
the consuming application, or list it in the generated package.json — wasm-pack does not do that automatically. async fn declarations return
Promises that Rust awaits as futures, as in
awaiting JavaScript promises from Rust.
Step 3 — import classes, statics and default exports
Classes are declared as extern types with constructors and methods:
#[wasm_bindgen(module = "/js/chart.js")]
extern "C" {
pub type Chart;
#[wasm_bindgen(constructor)]
fn new(canvas: &web_sys::HtmlCanvasElement) -> Chart;
#[wasm_bindgen(method)]
fn plot(this: &Chart, xs: &[f64], ys: &[f64]);
#[wasm_bindgen(static_method_of = Chart, js_name = defaultTheme)]
fn default_theme() -> JsValue;
}
For a module’s default export, bind the name default with js_name:
#[wasm_bindgen(module = "/js/legacy.js")]
extern "C" {
#[wasm_bindgen(js_name = default)]
fn legacy_init(config: &JsValue);
}
Step 4 — use inline_js for tiny helpers
For a few lines of JavaScript that do not deserve a file, embed them:
#[wasm_bindgen(inline_js = "export function now_ms() { return performance.now(); }")]
extern "C" {
fn now_ms() -> f64;
}
The string becomes a snippet file like any other. Keep inline JavaScript short; anything longer is easier to read, lint and test as a real file.
Step 5 — make it work in every target
Snippets are ES modules, which fits --target web and --target bundler. --target nodejs (CommonJS) has limitations with snippets in some versions;
prefer the experimental-nodejs-module target or web with Node’s ESM support. --target no-modules cannot use module imports at all, because its
output is a plain script — keep helpers in a separate global script for that target, or move off it. Bare package imports require a bundler, an import
map, or Deno’s npm specifiers. Test the package in each environment you ship to, as described in
targeting Node and browsers from one Wasm package.
Choosing between importing and passing in
Importing a module hard-wires the dependency into the package: the Rust code always calls that helper. Sometimes the caller should decide instead — a logging function, a storage backend, a renderer that differs between the browser and tests. For those, accept the JavaScript dependency as a parameter or in an initialisation call: an object with methods (typed as an extern type), or a callback. The application constructs it and passes it in, and the Rust code calls its methods through wasm-bindgen exactly as it would call an imported function. That keeps the Wasm package free of environment-specific imports, makes it easy to substitute fakes in tests, and lets one build work in a browser, a worker and Node. Imports remain the better choice for helpers that are truly part of the library — formatting, small algorithms, wrappers that exist only to make a JavaScript library callable from Rust — because they need no setup from the caller and are always present.
Keeping the JavaScript side healthy
JavaScript files inside a Rust crate are easy to neglect: they are not checked by cargo, and type errors surface only when the Rust code calls them.
Give them the same treatment as any JavaScript. Add a small package.json and tsconfig.json in the crate’s js/ directory, write the helpers in
TypeScript or with JSDoc types and check them with tsc --noEmit in CI, lint them, and unit-test them with Vitest without involving Wasm at all. Keep the
boundary narrow: one or two helper modules with a handful of functions each, rather than many scattered snippets, so the contract between Rust and
JavaScript is easy to review. When a helper grows into a substantial piece of logic, consider whether it belongs in the JavaScript application instead,
passed into Rust as a callback or an object, rather than being bundled into the Wasm package where its dependencies and versioning become the crate’s
responsibility. And remember that every import is a boundary crossing: helpers called in hot loops should do enough work per call to amortise it.
Expected output
describe(5_300_000) returns "Payload: 5.1 MB"; pkg/snippets/ contains js/format.js; the application bundles the package with Vite without extra
configuration; and calling Chart.new(canvas).plot(xs, ys) from Rust draws through the JavaScript class.
Gotchas
- Path without a leading slash.
module = "js/format.js"is treated as a package name. Use/js/format.jsfor crate files. - Bare imports without a bundler.
import "idb-keyval"fails in a browser without an import map. Bundle or map it. - Missing dependency. wasm-pack does not add imported npm packages to
package.json. Add them to the consumer. - CommonJS snippets. Snippets must be ES modules. Write
export, notmodule.exports. - Helpers drifting from their Rust declarations. A renamed JavaScript export fails only at load time. Type-check the helpers.
no-modulestarget. It cannot import modules. Use a different target.
Performance note
A call from Rust to an imported JavaScript function cost about 10–20 ns in Chrome for numeric arguments — the same as a web-sys call. Passing a string
added encoding cost proportional to its length. Snippets add their own size to the package, uncompressed, and are fetched as separate modules unless
bundled.
Frequently Asked Questions
Can I import TypeScript files directly?
Not with module = "/…" — the snippet is shipped as-is. Compile TypeScript to JavaScript first, or import a package that ships JavaScript.
Where does the snippet hash come from? It identifies the crate so snippets from different crates and versions cannot collide.
Can two crates import the same npm package? Yes. The bundler deduplicates the bare import like any other.
Can imported JavaScript call back into Rust?
Yes — pass a Closure or export Rust functions and import the generated module from the helper; see
passing closures between Rust and JavaScript.
Does inline_js work with --target web?
Yes. It becomes a snippet file imported by relative path, like a module file.
What happens if the imported function throws?
Without catch, the exception propagates through the Wasm frames to the JavaScript caller. With #[wasm_bindgen(catch)], Rust receives it as Err(JsValue).
Can I import a JSON file? Not directly as a module import in every bundler. Wrap it in a small JavaScript module that imports or fetches it and exports the data.
Related
- Reading the glue code wasm-bindgen generates — where the imports end up.
- Passing JS objects to Rust with wasm-bindgen — the other direction.
- Loading Wasm in Rollup and esbuild — bundling the package and its snippets.
- Throwing JavaScript exceptions from Rust — custom error classes via imports.
← Back to wasm-bindgen Deep Dive