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.
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.
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/clienttypes. - Importing the
.wasminstead of the glue. You lose generated types. Import the.jsglue module. - Wrong
moduleResolution. Packageexportsare ignored. Usebundlerornodenext. - 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.
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.
Related
- Generating TypeScript types from Wasm — producing declarations.
- Bundling Wasm ESM with Vite — the bundler side.
- Exposing Wasm from a library with a clean ESM API — typed public surfaces.
- Writing an import object by hand — typing imports too.
← Back to ESM Bindings & Module Generation