Generating TypeScript Types from Wasm
This guide shows how wasm-pack/wasm-bindgen emit a .d.ts declaration file for a compiled
WebAssembly module, how to consume those types in a TypeScript application, and how to hand-write a
declaration when you only have a raw .wasm with no generated glue.
Types matter more here than in an ordinary JavaScript dependency, because the boundary is
lossy by construction. Everything that actually crosses it is a number, a pointer, or a length; the
string, class, and bigint you see in an editor are conveniences the generated glue reconstructs
on each side. A correct declaration file is therefore the only place where the intended contract is
written down in a form the compiler can check — and when you hand-write one for a raw binary, you are
writing that contract yourself, with no tool verifying it against the module.
Prerequisites
- [ ]
wasm-pack≥ 0.12 (cargo install wasm-pack) and a Rust crate using#[wasm_bindgen] - [ ] TypeScript ≥ 5.0 with
tsconfig.jsonmoduleResolutionset tobundlerornode16 - [ ] A bundler that resolves
.wasmimports (Vite ≥ 5 withvite-plugin-wasm, or Webpack ≥ 5) - [ ]
targetset toes2022oresnextintsconfig.jsonfor top-levelawait
How wasm-bindgen emits declarations
When you annotate a Rust function with #[wasm_bindgen], the tool reads the function signature and
maps each Rust type to a TypeScript type — &str and String become string, u32/i32 become
number, f64 becomes number, i64/u64 become bigint, and a #[wasm_bindgen] struct becomes
a class with a free() method. It then writes a .d.ts next to the .js glue so the boundary is
fully typed. The declaration describes the JavaScript surface the glue exposes, not the raw integer
ABI of the binary.
Procedure
-
Annotate the Rust source so
wasm-bindgenhas signatures to read:use wasm_bindgen::prelude::*; #[wasm_bindgen] pub fn greet(name: &str) -> String { format!("Hello, {name}!") } #[wasm_bindgen] pub struct Histogram { buckets: Vec<u32>, } #[wasm_bindgen] impl Histogram { #[wasm_bindgen(constructor)] pub fn new(n: usize) -> Histogram { Histogram { buckets: vec![0; n] } } pub fn record(&mut self, value: u32) { /* ... */ } pub fn total(&self) -> u64 { self.buckets.iter().map(|&b| b as u64).sum() } } -
Build with
wasm-pack— generation of the.d.tsis automatic, no extra flag required:wasm-pack build --target web --out-dir pkg --release -
Read the emitted declaration. Open
pkg/core.d.tsto see the typed surface:// pkg/core.d.ts (excerpt) /* tslint:disable */ export function greet(name: string): string; export class Histogram { free(): void; constructor(n: number); record(value: number): void; total(): bigint; } export default function init( module_or_path?: InitInput | Promise<InitInput> ): Promise<InitOutput>;Note
total(): bigint— the Rustu64maps tobigint, and the struct gained an explicitfree()because the JavaScript object holds a pointer intolinear memorythat must be released. -
Point
package.jsonat the types so the bundler and editor resolve them.wasm-packwrites this for you; confirm it is present:{ "module": "core.js", "types": "core.d.ts", "sideEffects": false } -
Consume the types in a TypeScript app. The editor now type-checks every call:
import init, { greet, Histogram } from "./pkg/core.js"; await init(); const message: string = greet("Wasm"); // string — checked const h = new Histogram(8); h.record(3); const total: bigint = h.total(); // bigint — note the type h.free(); // required: release the pointer -
Set
moduleResolutioncorrectly so TypeScript follows thetypesfield inpackage.json:{ "compilerOptions": { "target": "es2022", "module": "esnext", "moduleResolution": "bundler", "strict": true, "skipLibCheck": true } } -
Check the types in CI, not just in the editor. A declaration that only your editor reads will drift the moment someone builds on a different machine. Run the compiler as a build step:
wasm-pack build --target web --out-dir pkg --release npx tsc --noEmit # fails if the generated types no longer satisfy the callersRunning the build before the type check matters:
tscreads the freshly generatedpkg/core.d.ts, so a Rust signature change that breaks a JavaScript caller fails the pipeline instead of reaching a browser. This is the cheapest possible regression test for the boundary, and it costs a couple of seconds.
Expected output
A successful build leaves the declaration alongside the binary and glue:
pkg/
├── core_bg.wasm
├── core.js
├── core.d.ts # the generated declarations
└── package.json
Running tsc --noEmit against the consuming app should report zero errors, and hovering greet in
the editor should show function greet(name: string): string.
Hand-writing a .d.ts for a raw .wasm
If you have only a hand-built .wasm (no wasm-bindgen), there is no glue and no generated types — a
raw module exports nothing but integer-typed functions and a memory. Declare the instance surface
yourself so callers get checking:
// sum.wasm.d.ts — hand-written for a raw module
export interface SumExports {
memory: WebAssembly.Memory;
sum_bytes(ptr: number, len: number): number;
}
export async function loadSum(url: string): Promise<SumExports> {
const { instance } = await WebAssembly.instantiateStreaming(fetch(url));
return instance.exports as unknown as SumExports;
}
The as unknown as SumExports cast is unavoidable: WebAssembly.Instance["exports"] is typed as
WebAssembly.Exports (an index signature of ExportValue), so you assert the concrete shape once at
the boundary and get full checking everywhere downstream. The same raw-ABI pointer-and-length
convention is detailed in
encoding strings across the Wasm boundary.
A hand-written declaration is a promise, not a proof. Nothing checks it against the binary, so it
drifts the moment the module gains a parameter or changes a return type, and the failure appears at
run time as a silent wrong answer or a TypeError deep inside a call. Two habits keep it honest:
generate the declaration from the module’s own export list rather than from memory, and assert the
shape at load time so a mismatch fails loudly at startup instead of during a computation.
// Cheap runtime guard for a hand-written declaration
const required = ["memory", "sum_bytes"] as const;
for (const name of required) {
if (!(name in instance.exports)) {
throw new Error(`wasm export missing: ${name} — declaration is out of date`);
}
}
wasm-objdump -x module.wasm | grep Export prints the authoritative list to write that array from.
Regenerating the declaration whenever the binary is rebuilt — and failing the build when the export
list changes — turns an invisible drift into an ordinary CI failure.
Gotchas
i64isbigint, notnumber. Any export returning a 64-bit integer types asbigint, and mixing it with anumbervia+throwsTypeError: Cannot mix BigInt and other types, use explicit conversions. Convert deliberately withNumber(value)only when the magnitude is safe.free()is in the types for a reason. Every generated class carries it because the JavaScript object is a handle over an allocation the engine’s garbage collector cannot see. TypeScript will not warn you for skipping the call — the declaration types it as optional to invoke, not optional to need. Treat a missingfree()the way you would a missingclose()on a file handle, and prefer atry/finallyaround any object with a non-trivial lifetime.skipLibCheck: falsesurfaces errors inside the generated file. The emitted declaration targets a recent lib set; with library checking on and an olderlibconfigured,tscreports errors incore.d.tsitself rather than in your code. Raiselibtoes2022(needed forbigintanyway) instead of editing the generated file, which is overwritten on every build.- No types for a raw
.wasm. A module compiled withoutwasm-bindgenships no.d.ts; importing it givesinstance.exportstyped as the broadWebAssembly.Exports, so calls are unchecked until you hand-write a declaration as above. - Resolution errors. With
moduleResolution: "node"(the legacy default) TypeScript ignores theexports/typesmap and reportsCould not find a declaration file for module './pkg/core.js'. Switch tobundlerornode16so thepackage.jsontypesfield is honoured. - Stale declarations after an ABI change. The
.d.tsis regenerated on everywasm-pack build; if you edit the Rust signature but reuse an oldpkg/, the types lie. Rebuild, or wire the build into yourprebuildscript.
Performance note
The .d.ts is a compile-time artifact only — it is consumed by tsc and your editor and is never
fetched or parsed at runtime, so typed bindings add exactly zero bytes and zero milliseconds to the
shipped bundle. The runtime cost lives entirely in the .wasm and the glue, not the declarations.
What the types do influence is which calls you write, and that has a real cost. A declaration that exposes a per-element accessor invites a loop that crosses the boundary once per element; a declaration that exposes a batch method invites one crossing for the whole array. Because the types are the API most developers ever read, shaping them around coarse-grained operations is a performance decision disguised as an ergonomics one — and it is far easier to make at design time than to retrofit after callers depend on the fine-grained surface.
Frequently Asked Questions
Can I generate types without building the whole crate?
Not from wasm-pack alone — the .d.ts is a byproduct of the wasm-bindgen step that runs after
compilation, so you need at least a release or dev build. There is no standalone “types only” command.
Why does my struct export have a free() method in the types?
A #[wasm_bindgen] struct is backed by an object living in linear memory; the JavaScript class holds
its pointer. free() releases that allocation. The generated .d.ts surfaces it so TypeScript reminds
you to call it, since forgetting leaks memory inside the module.
Related
- wasm-bindgen deep dive — how the glue that these types describe marshals values.
- Bundling Wasm ESM with Vite — resolving the typed
.wasmimport in a build. - Encoding strings across the Wasm boundary — the raw ABI a hand-written
.d.tsdescribes.
← Back to ESM Bindings & Module Generation