Importing Wasm in TypeScript Projects

This page answers one task: a TypeScript project imports a WebAssembly module — through a bundler, generated glue or a hand-written loader — and the compiler reports Cannot find module './engine.wasm', infers any for every export, or resolves the generated package incorrectly, and you want precise types with no errors anywhere in the project.

Prerequisites

  • [ ] TypeScript 5.x with a bundler (Vite, webpack, esbuild) or Node with ES modules.
  • [ ] A Wasm module: generated by wasm-bindgen or Emscripten, or hand-written with known exports.
  • [ ] Access to tsconfig.json.

Why TypeScript does not understand .wasm files

TypeScript type-checks JavaScript and TypeScript sources and reads .d.ts declarations for everything else. A .wasm file is neither: the compiler cannot open it, so import url from "./engine.wasm?url" or import * as engine from "./engine.wasm" fails with “Cannot find module” unless a declaration says what such imports produce. The bundler handles the import at build time; TypeScript just needs to be told its type.

The second problem is precision. Even when an import resolves, the exports are only as well typed as their declarations. Generated glue from wasm-bindgen and Emscripten can ship .d.ts files with real signatures; hand-written loaders using WebAssembly.instantiate produce WebAssembly.Exports, which is a record of ExportValue — effectively untyped. The goal is precise types at the boundary that the rest of the codebase consumes, with any confined to one small file at most.

How a Wasm import gets its type An import of a .wasm URL or module is resolved by the bundler at build time. TypeScript needs a declaration describing the import's type: an ambient module for raw .wasm imports, generated .d.ts files for glue packages, or a hand-written interface for exports loaded manually. import in .ts file ./engine.wasm?url bundler resolves at build time TypeScript checks needs a declaration declaration source ambient / generated / hand-written typed exports no any at call sites

Step 1 — declare raw .wasm imports

For bundlers that import .wasm files as URLs (Vite’s ?url, webpack asset modules) or as initialisers, add an ambient declaration in a .d.ts included by tsconfig.json:

// src/types/wasm.d.ts
declare module "*.wasm?url" {
  const url: string;
  export default url;
}

declare module "*.wasm?init" {                  // Vite: returns an instantiation function
  const init: (imports?: WebAssembly.Imports) => Promise<WebAssembly.Instance>;
  export default init;
}

Vite’s vite/client types already declare many of these; add "types": ["vite/client"] to compilerOptions instead of writing them by hand when using Vite. Webpack users declare whatever their loader configuration produces.

Step 2 — use generated declarations from glue

wasm-bindgen writes pkg/<name>.d.ts with precise signatures for every export, including classes, and Emscripten can emit declarations with --emit-tsd. Import the glue module, not the .wasm, and TypeScript picks up the types:

import init, { parse, Document } from "../pkg/engine.js";   // types from pkg/engine.d.ts
await init();
const doc: Document = parse(source);                         // fully typed

If imports resolve to the .js file but types are missing, check moduleResolution. With "moduleResolution": "bundler" (for bundled apps) or "node16"/"nodenext" (for Node), TypeScript follows the package’s exports and types fields. Generated packages need a types entry — wasm-pack adds it; hand-assembled packages may not. Improving the precision of generated types is covered in customising TypeScript output from wasm-bindgen.

Step 3 — type hand-written loaders

For modules loaded directly with the WebAssembly API, declare an interface for the exports and cast once, in the loader:

interface EngineExports {
  memory: WebAssembly.Memory;
  alloc(size: number): number;
  free(ptr: number, size: number): void;
  checksum(ptr: number, len: number): number;
}

export async function loadEngine(url: string): Promise<EngineExports> {
  const { instance } = await WebAssembly.instantiateStreaming(fetch(url), {});
  return instance.exports as unknown as EngineExports;       // the only unchecked cast
}

Then wrap the raw exports in a typed, idiomatic API — checksum(data: Uint8Array): number — so pointer arithmetic stays inside the wrapper. A runtime check that each declared export exists (WebAssembly.Module.exports(module)) turns a mismatched binary into a clear error instead of a type that lies.

Three sources of types for Wasm imports Ambient declarations give raw .wasm imports a URL or initialiser type. Generated declarations from wasm-bindgen or Emscripten give glue modules precise signatures. Hand-written interfaces type exports loaded manually, with one cast confined to the loader and verified at runtime. ambient declaration *.wasm?url → string covers bundler imports no export types raw asset imports generated .d.ts from wasm-bindgen / emcc precise signatures updates with the build glue packages hand-written interface for manual loaders one cast in the loader verify exports at runtime small raw modules

Step 4 — configure tsconfig for generated code

Generated glue is JavaScript with declarations; do not let TypeScript type-check its implementation. Keep allowJs off or exclude the pkg directory from compilation, and keep skipLibCheck on if generated declarations use patterns your strict settings reject. Make sure the .d.ts files are inside the project’s include paths, or referenced through package resolution, so editors see them. For monorepos, reference the generated package as a workspace dependency rather than a relative path, so the same resolution works in the editor, the bundler and tests.

Step 5 — keep types in sync with the binary

Generated declarations change whenever the Rust or C exports change. Regenerate them in the same build step that produces the binary, and fail CI if committed declarations differ from regenerated ones — or do not commit them at all and generate them before type-checking. A type test that calls each export with the expected argument types, compiled with tsc --noEmit, catches drift that would otherwise appear as runtime errors.

Types in tests and workers

Code that loads the module in Web Workers or test runners needs the same declarations in those contexts. Worker scripts compiled with a separate tsconfig (with "lib": ["webworker"]) must also include the ambient declarations and resolve the generated package. Test runners like Vitest use Vite’s resolution, so ?url and ?init imports behave as in the app, but tests running in Node may need the module loaded from disk instead; a small test helper that reads the binary with fs and passes it to the glue’s initSync keeps tests fast and typed. Sharing one types/wasm.d.ts file across all tsconfig projects in the repository prevents the declarations from diverging between the app, workers and tests.

Strictness at the boundary

The boundary is where type safety most often erodes: any returned from a loader spreads through call sites, and numbers that are really pointers mix with numbers that are counts. Two habits help. Use branded types for pointers — type Ptr = number & { readonly __brand: "Ptr" } — so a length cannot be passed where a pointer is expected; the cost is zero at runtime. And never expose raw exports outside the wrapper module; the rest of the application imports only the typed API. With those, TypeScript catches most boundary mistakes at compile time.

Typing the import object as well

Exports are only half of the boundary. Modules with imports — callbacks into JavaScript, host functions, memory — need a correctly shaped import object, and a typo there fails only at instantiation. Declare an interface for the imports alongside the exports and type the object you pass, so a missing or misnamed host function is a compile error:

interface EngineImports {
  env: { log(level: number, ptr: number, len: number): void; now(): number };
}
const imports: EngineImports = { env: { log: hostLog, now: () => performance.now() } };
await WebAssembly.instantiateStreaming(fetch(url), imports as unknown as WebAssembly.Imports);

For generated glue, the generator builds the import object itself, so this matters mainly for hand-written loaders and for modules that import host functions you implement. Keeping both interfaces in one file, next to the loader, gives readers a complete picture of the module’s contract in TypeScript.

Expected output

tsc --noEmit passes; import wasmUrl from "./engine.wasm?url" has type string; parse from the generated package has a precise signature in the editor; the hand-written loader exposes EngineExports with one cast verified at runtime; and the type test fails if an export’s signature changes.

Gotchas

  • “Cannot find module ‘./x.wasm’”. Add an ambient declaration or vite/client types.
  • Importing the .wasm instead of the glue. You lose generated types. Import the .js glue module.
  • Wrong moduleResolution. Package exports are ignored. Use bundler or nodenext.
  • Casting in many places. Confine the cast to the loader and verify exports at runtime.
  • Stale committed declarations. Regenerate them with the binary or check them in CI.
  • Separate tsconfigs for workers. They need the same ambient declarations. Share one wasm.d.ts.

Performance note

Types have no runtime cost. The only runtime addition was the export verification in the loader, which took about 0.02 ms per load. Branded pointer types caught three argument-order bugs at compile time during one refactor.

Untyped exports before and after typing the boundary Number of call sites using any-typed Wasm exports in a TypeScript codebase before adding declarations, after adding ambient and generated declarations, and after wrapping raw exports in a typed API. call sites with any-typed Wasm values no declarations 64 ambient + generated .d.ts 9 typed wrapper API 0

Frequently Asked Questions

Can TypeScript infer types from the .wasm file directly? No. Types come from declarations; tools such as wasm-bindgen and some community generators produce them from the binary or source.

Do I need allowArbitraryExtensions? Only if you write declarations as engine.d.wasm.ts files next to the binary; ambient wildcard modules avoid it.

How do I type Emscripten’s Module object? Use --emit-tsd or @types/emscripten, and extend it with your exported functions.

What about Deno? Deno can type direct .wasm imports from the binary’s export signatures, as described in the Deno guide.

Should the import object be typed too? Yes, for hand-written loaders — a typed interface catches misnamed host functions at compile time instead of at instantiation.

Why does the editor find types but the build fails? The editor and the build use different tsconfig files or resolution settings. Align moduleResolution and include across them.

Do generated .d.ts files need committing? No — generate them in the build so they always match the module they describe.

← Back to ESM Bindings & Module Generation