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, or wasm-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.

How the engine resolves one import For each import entry, the engine reads its module and field names, looks the value up in the import object, checks its kind, checks its type or limits, and binds it. Any failed step aborts instantiation with a LinkError naming the import. import entry env . log : func (i32) importObject. env.log lookup by names kind check is it a function? type / limits check signature, memory size bound callable from Wasm

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.

Import kinds and what the engine accepts for each For each import kind, the JavaScript value the import object must contain and the checks the engine performs before binding it. kind provide engine checks function any callable callable; types converted per call memory WebAssembly.Memory initial ≥ min, max ≤ declared max, shared flag table WebAssembly.Table element type, size limits global Global or number value type, mutability tag WebAssembly.Tag exception tag type

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. Return 5n.
  • Mutable global provided as a number. Mutable global imports need a WebAssembly.Global with mutable: true.
  • Capturing exports too early. An import that captured a variable before instantiation finished sees undefined. Assign after instantiate resolves.
  • 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.

Instantiation time by number of imports Time to instantiate an already compiled module in Chrome with 12 hand-written imports and with 340 wasm-bindgen generated imports. microseconds to instantiate 12 hand-written imports 40 µs 340 generated imports 900 µs

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.

← Back to Wasm Instantiation Lifecycle