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.
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 } }
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.
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.- 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.
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