Writing an Import Object by Hand
This page answers one task: instantiate a WebAssembly module that has imports, without wasm-bindgen or Emscripten glue, by writing the import object yourself — and understand exactly what the engine checks.
Prerequisites
- [ ] A module with imports — your own WAT, a freestanding C build, or a module whose glue you want to replace.
- [ ] A way to list its imports:
WebAssembly.Module.imports(),wasm-objdump -j Import, orwasm-tools print --skeleton.
What the import object is
WebAssembly.instantiate(module, importObject) links a module to the outside world. The import object is a plain JavaScript object of objects:
the outer keys are module names and the inner keys are field names, mirroring the two strings every import entry carries in the binary. For
each import, the engine looks up importObject[module][field], checks that the value is the right kind of thing — a function, a
WebAssembly.Memory, a WebAssembly.Table, a WebAssembly.Global or a number — and that it fits the declared type, then binds it.
Generated glue builds this object for you, often with hundreds of entries. Writing it by hand is worth doing at least once, because it is the clearest view of the contract between a module and its host, and because small modules — hand-written WAT, freestanding C, plugin interfaces you designed — usually need only a few imports that you can provide directly. The reading side of the same contract is covered in reading the import and export sections.
Step 1 — list what the module needs
const module = await WebAssembly.compileStreaming(fetch("app.wasm"));
for (const imp of WebAssembly.Module.imports(module)) {
console.log(`${imp.module}.${imp.name}: ${imp.kind}`);
}
env.memory: memory
env.log_i32: function
env.now_ms: function
env.scale: global
The reflection API gives names and kinds but not function signatures or memory limits. Get those from the binary:
wasm-tools print --skeleton app.wasm | grep import
(import "env" "memory" (memory (;0;) 2 256))
(import "env" "log_i32" (func (;0;) (type 0))) ;; type 0: (param i32)
(import "env" "now_ms" (func (;1;) (type 1))) ;; type 1: (result f64)
(import "env" "scale" (global (;0;) f32))
Step 2 — write the object
const memory = new WebAssembly.Memory({ initial: 2, maximum: 256 });
const scale = new WebAssembly.Global({ value: "f32", mutable: false }, 1.5);
const importObject = {
env: {
memory,
log_i32: (x) => console.log("module says", x),
now_ms: () => performance.now(),
scale, // a Global, or a plain number for immutable globals
},
};
const instance = await WebAssembly.instantiate(module, importObject);
Each entry’s requirements differ by kind. Functions can be any JavaScript callable; the engine converts arguments and results according to the
import’s declared types. Memories must be WebAssembly.Memory objects whose initial size is at least the import’s minimum and whose maximum, if
the import declares one, is no larger. Tables likewise. Globals can be a WebAssembly.Global of the right type and mutability, or — for
immutable globals of number types — a plain number.
Step 3 — understand argument and result conversion
Imported JavaScript functions do not declare types; the import does, and the engine converts at the boundary:
Wasm type value passed to JS JS return converted with
i32 Number (signed 32-bit) ToInt32
i64 BigInt ToBigInt64 (throws on Number)
f32 / f64 Number ToNumber
externref any JS value passed through
That has consequences. A function imported as (result i32) that returns 3.9 gives the module 3; one that returns "abc" gives 0. An
i64 result must be a BigInt — returning a plain number throws a TypeError at the call. Unused parameters are simply ignored, and missing
ones are undefined, which converts to 0 or NaN. The engine is lenient about what the JavaScript function looks like and strict about
converting values, so validate inside imports that receive untrusted values.
Step 4 — read the LinkError when it fails
When something is missing or mismatched, instantiation throws WebAssembly.LinkError and names the import:
LinkError: WebAssembly.instantiate(): Import #1 "env" "log_i32": function import requires a callable
LinkError: WebAssembly.instantiate(): Import #0 "env" "memory": memory import has 1 pages which is smaller than the declared initial of 2
LinkError: WebAssembly.instantiate(): Import #3 "env" "scale": global import must be a number, valid Wasm reference, or WebAssembly.Global object
The message contains the import’s position, module name and field name, and the reason. The fixes are usually immediate: a typo in a key, a
missing nested object ({ log_i32 } at the top level instead of under env), a memory created too small. Handling these errors at runtime is
discussed in handling CompileError and LinkError.
Step 5 — keep it narrow and testable
Hand-written import objects are a good place to enforce discipline. Give the module only what it needs — a logging function rather than
console, a clock rather than performance — so the import object documents its capabilities, as discussed in
restricting what a module can import.
Build the object in a factory function so tests can substitute fakes: a deterministic clock, a log collector, a memory pre-filled with fixtures.
That makes the module testable without a browser and without the real host.
export function makeImports({ log = console.log, now = () => performance.now() } = {}) {
const memory = new WebAssembly.Memory({ initial: 2, maximum: 256 });
return { memory, imports: { env: { memory, log_i32: log, now_ms: now, scale: 1.5 } } };
}
Imports that call back into the module
Imports often need to read or write the module’s memory — a logging function receiving a pointer and length, a host function filling a buffer the module provides. The import object is created before the instance exists, so the import cannot capture the instance’s exports directly. The usual pattern is a small mutable reference that is filled in after instantiation:
let exports; // assigned once instantiation finishes
const decoder = new TextDecoder();
const importObject = {
env: {
log_str: (ptr, len) => {
const bytes = new Uint8Array(exports.memory.buffer, ptr, len); // read at call time, not earlier
console.log(decoder.decode(bytes));
},
},
};
({ exports } = await WebAssembly.instantiate(module, importObject));
Reading exports.memory.buffer inside the function, at the time of each call, matters: if memory grew since the last call, an older view would
be detached. If the module imports its memory instead of exporting it, the import function can close over the WebAssembly.Memory you created,
which avoids the late binding altogether — one more reason to consider host-created memory for modules with chatty imports.
When to stop writing it by hand
Hand-written import objects scale to a few dozen imports with simple types. Beyond that, or as soon as strings, objects and callbacks cross the boundary, the bookkeeping — copying strings into memory, keeping object handles, matching hashed import names that change between builds — is exactly what glue generators exist to do. wasm-bindgen’s import names, for example, include hashes that change when signatures change, so a hand-written object for a wasm-bindgen module would break on every rebuild. Use hand-written objects for modules whose interface you own and designed to be small, and generated glue for everything else.
Expected output
With the object above, instantiation succeeds and calls from the module reach JavaScript:
module says 42
and instance.exports contains the module’s exports.
Gotchas
- Flat object instead of nested.
{ log_i32 }is not{ env: { log_i32 } }. The outer key must be the module name. - Returning a Number for an i64 result. Throws
TypeError: Cannot convert 5 to a BigInt. Return5n. - Mutable global provided as a number. Mutable global imports need a
WebAssembly.Globalwithmutable: true. - Capturing exports too early. An import that captured a variable before instantiation finished sees
undefined. Assign afterinstantiateresolves. - Memory too small. The provided memory’s initial size must cover the import’s minimum. Read the import’s limits.
Performance note
The import object is consulted once, at instantiation; calls through imports afterwards are as fast as any boundary crossing. Instantiating a module with 12 hand-written imports took about 40 µs in Chrome, compared with about 900 µs for a wasm-bindgen module with 340 generated imports — most of the difference being the number of entries to resolve and check.
Frequently Asked Questions
Can the same function satisfy several imports? Yes. Put the same function under several keys; each import binds independently.
Are extra keys in the import object a problem? No. The engine only looks up what the module imports and ignores everything else.
Can I import a function exported by another module?
Yes — pass otherInstance.exports.fn as the value. Calls between the two modules then go directly, without converting through JavaScript values.
Does the import object work the same in Node and Deno?
Yes; it is part of the standard JavaScript API. WASI imports come from a WASI implementation’s getImportObject() or wasiImport.
Related
- Providing memory at instantiation — the memory import in depth.
- Reflecting on module imports and exports — listing what to provide.
- Building a Wasm module without libc — modules small enough for hand-written imports.
- Fixing undefined symbol errors from wasm-ld — where unexpected imports come from.
← Back to Wasm Instantiation Lifecycle