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.json moduleResolution set to bundler or node16
  • [ ] A bundler that resolves .wasm imports (Vite ≥ 5 with vite-plugin-wasm, or Webpack ≥ 5)
  • [ ] target set to es2022 or esnext in tsconfig.json for top-level await

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

  1. Annotate the Rust source so wasm-bindgen has 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() }
    }
  2. Build with wasm-pack — generation of the .d.ts is automatic, no extra flag required:

    wasm-pack build --target web --out-dir pkg --release
  3. Read the emitted declaration. Open pkg/core.d.ts to 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 Rust u64 maps to bigint, and the struct gained an explicit free() because the JavaScript object holds a pointer into linear memory that must be released.

  4. Point package.json at the types so the bundler and editor resolve them. wasm-pack writes this for you; confirm it is present:

    {
      "module": "core.js",
      "types": "core.d.ts",
      "sideEffects": false
    }
  5. 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
  6. Set moduleResolution correctly so TypeScript follows the types field in package.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

  • i64 is bigint, not number. Any export returning a 64-bit integer types as bigint, and mixing it with a number via + throws TypeError: Cannot mix BigInt and other types, use explicit conversions. Convert deliberately with Number(value) only when the magnitude is safe.
  • No types for a raw .wasm. A module compiled without wasm-bindgen ships no .d.ts; importing it gives instance.exports typed as the broad WebAssembly.Exports, so calls are unchecked until you hand-write a declaration as above.
  • Resolution errors. With moduleResolution: "node" (the legacy default) TypeScript ignores the exports/types map and reports Could not find a declaration file for module './pkg/core.js'. Switch to bundler or node16 so the package.json types field is honoured.
  • Stale declarations after an ABI change. The .d.ts is regenerated on every wasm-pack build; if you edit the Rust signature but reuse an old pkg/, the types lie. Rebuild, or wire the build into your prebuild script.

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.

← Back to ESM Bindings & Module Generation