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.

Three views of the same function The Rust source declares a string parameter and a string return. The compiled export takes and returns integers — a pointer and a length. The generated declaration file describes the JavaScript surface the glue rebuilds, not the raw numeric ABI. Rust source fn greet(name: &str) -> String what you wrote the compiled export (i32, i32) -> i32 ptr, len → ptr into linear memory all the binary really knows generated .d.ts greet(name: string) : string what your editor checks compile bindgen the glue closes the gap at run time encodes the string into linear memory, passes ptr + len, then decodes the returned pointer back into a JS string Drop the glue and the types describe nothing that exists.

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

    Running the build before the type check matters: tsc reads the freshly generated pkg/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.

Generated declarations track the binary; hand-written ones do not When the Rust source changes, wasm-bindgen rewrites the declaration file in the same build, so the compiler sees the new signature. A hand-written declaration for a raw module keeps describing the old shape until someone updates it, and the mismatch only surfaces at run time. generated with wasm-bindgen signature changes wasm-pack build .d.ts rewritten tsc fails on the caller — good hand-written for a raw binary signature changes binary rebuilt .d.ts unchanged tsc passes — wrong at run time A startup assertion over the export list converts the lower path back into a loud, early failure.

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.
  • 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 missing free() the way you would a missing close() on a file handle, and prefer a try/finally around any object with a non-trivial lifetime.
  • skipLibCheck: false surfaces errors inside the generated file. The emitted declaration targets a recent lib set; with library checking on and an older lib configured, tsc reports errors in core.d.ts itself rather than in your code. Raise lib to es2022 (needed for bigint anyway) instead of editing the generated file, which is overwritten on every build.
  • 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.

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.

The declaration shapes the call pattern An accessor-shaped API leads to one crossing per element, dominated by marshalling. A batch-shaped API leads to a single crossing that hands over a typed array and returns a result, with the loop running inside the module. record(value: number): void — called n times n crossings · n glue conversions work recordAll(values: Uint32Array): void — called once one crossing, then the loop runs inside the module same result, one conversion The declaration file costs nothing at run time — but the shape it advertises decides how often the boundary is crossed.

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