Customising TypeScript Output from wasm-bindgen

This page answers one task: the TypeScript declarations wasm-bindgen generates for a Rust crate are full of any — every JsValue parameter, every serde-converted result — and callers should instead see precise types that match what the functions really accept and return.

Prerequisites

  • [ ] A crate built with wasm-bindgen (directly or through wasm-pack), producing a .d.ts file.
  • [ ] A TypeScript project consuming the generated package.

Where wasm-bindgen’s types come from

wasm-bindgen writes a TypeScript declaration file next to the generated JavaScript. For parameters and return types it understands — numbers, bool, String/&str, typed-array slices, exported structs and enums, Option of those — the declarations are precise. For JsValue, which can be anything, it writes any. Unfortunately JsValue is exactly what idiomatic code uses for structured data: configuration objects deserialised with serde-wasm-bindgen, callbacks, results built as plain objects. The result is an API whose most important functions are untyped.

wasm-bindgen provides attributes to fill the gaps: you can inject arbitrary TypeScript into the declaration file, and tell wasm-bindgen which declared type to use for a given parameter or return value. The generated JavaScript does not change — these attributes only affect the .d.ts — so they cost nothing at runtime.

Attributes that shape the generated declarations A custom section injects interface declarations into the d.ts file. Imported extern types with typescript_type give JsValue-like parameters a declared type. unchecked_return_type overrides a return type. skip_typescript omits an item. Doc comments become TSDoc. #[wasm_bindgen(typescript_custom_section)] inject interfaces #[wasm_bindgen(typescript_type = "Options")] name a declared type #[wasm_bindgen(unchecked_return_type = "Hit[]")] override a return type #[wasm_bindgen(skip_typescript)] omit from the d.ts /// Searches the index… becomes TSDoc

Step 1 — declare interfaces with a custom section

Write the TypeScript you want in a string constant marked as a custom section. wasm-bindgen copies it verbatim into the .d.ts:

#[wasm_bindgen(typescript_custom_section)]
const TS_TYPES: &'static str = r#"
export interface SearchOptions {
  query: string;
  maxResults: number;
  fuzzy?: boolean;
  fields?: string[];
}

export interface Hit {
  id: number;
  score: number;
  title: string;
  snippet: string;
}
"#;

These interfaces should describe exactly what serde produces and accepts — including rename_all = "camelCase" renames and optional fields marked with #[serde(default)] or Option. Keep the custom section next to the Rust structs so a change to one prompts a change to the other.

A custom section can declare anything TypeScript allows — interfaces, type aliases, enums, even module augmentations — but it cannot import types from other packages reliably, because the generated file’s location and module format vary by target. Keep custom declarations self-contained, and declare simplified local versions of external types when you need them.

Step 2 — attach the types to JsValue parameters

Declare an imported “extern type” that is a JsValue at runtime but has a TypeScript name, and use it in the signature:

#[wasm_bindgen]
extern "C" {
    #[wasm_bindgen(typescript_type = "SearchOptions")]
    pub type SearchOptionsJs;

    #[wasm_bindgen(typescript_type = "Hit[]")]
    pub type HitArray;
}

#[wasm_bindgen]
pub fn search(options: SearchOptionsJs) -> Result<HitArray, JsValue> {
    let opts: SearchOptions = serde_wasm_bindgen::from_value(options.into())?;
    let hits = run_search(&opts);
    Ok(serde_wasm_bindgen::to_value(&hits)?.unchecked_into())
}

The declaration becomes export function search(options: SearchOptions): Hit[];. unchecked_into converts the JsValue to the extern type without a runtime check — the types are a promise, and serde is what makes it true. Newer wasm-bindgen versions also support unchecked_param_type and unchecked_return_type attributes directly on a function, which avoid declaring extern types for one-off cases:

#[wasm_bindgen(unchecked_return_type = "Hit[]")]
pub fn top_hits(#[wasm_bindgen(unchecked_param_type = "SearchOptions")] options: JsValue) -> Result<JsValue, JsValue> { … }

Step 3 — type callbacks and unions

Callbacks are js_sys::Function or &Closure<…> in Rust and become Function in TypeScript — callable with anything. Give them a precise signature the same way:

#[wasm_bindgen(typescript_custom_section)]
const TS_CB: &'static str = r#"
export type ProgressCallback = (done: number, total: number) => void;
export type Shape =
  | { type: "circle"; cx: number; cy: number; r: number }
  | { type: "rect"; x: number; y: number; w: number; h: number };
"#;

#[wasm_bindgen]
extern "C" {
    #[wasm_bindgen(typescript_type = "ProgressCallback")]
    pub type ProgressCallback;
}

#[wasm_bindgen]
pub fn encode(frames: &[u8], on_progress: &ProgressCallback) { /* call via .unchecked_ref::<js_sys::Function>() */ }

Discriminated unions like Shape match serde’s internally tagged enums, as described in passing enums across the boundary, and TypeScript then narrows on the tag in switch statements.

Step 4 — hide internals and document the rest

Some exports exist only for the glue or for tests. #[wasm_bindgen(skip_typescript)] keeps them out of the declarations so they do not appear in autocompletion. And Rust doc comments on exported items become TSDoc comments in the .d.ts, which editors show on hover — a cheap way to document error cases and units:

/// Searches the index.
///
/// @throws {Error} with `name === "QueryError"` if the query cannot be parsed.
#[wasm_bindgen(unchecked_return_type = "Hit[]")]
pub fn search2(…) -> Result<JsValue, JsValue> { … }

Step 5 — generate interfaces instead of hand-writing them

Hand-written interfaces drift from the Rust structs. The tsify crate (and its maintained forks) derives TypeScript declarations from the same types serde uses, and wires them into wasm-bindgen so that functions can take and return the Rust types directly:

use tsify::Tsify;

#[derive(Serialize, Deserialize, Tsify)]
#[tsify(into_wasm_abi, from_wasm_abi)]
#[serde(rename_all = "camelCase")]
pub struct SearchOptions { pub query: String, pub max_results: u32, #[tsify(optional)] pub fuzzy: Option<bool> }

#[wasm_bindgen]
pub fn search3(options: SearchOptions) -> Vec<Hit> { … }      // d.ts: (options: SearchOptions) => Hit[]

The derive honours serde’s renames, so the TypeScript property names always match the wire format. For a sizeable API this is the most reliable option: the Rust types are the single source of truth, and a renamed field changes the declaration automatically.

Ways to type JsValue-based APIs Leaving the defaults produces any for every JsValue. A hand-written custom section with typescript_type gives precise types but can drift. Deriving declarations with tsify keeps the TypeScript in step with the Rust types automatically. defaults JsValue becomes any callbacks become Function no compile-time checks avoid for public APIs custom section + typescript_type precise, flexible hand-maintained can drift from Rust small APIs, unions tsify derive generated from Rust types follows serde renames one source of truth larger APIs

Wrapping the generated package in a typed facade

Even with precise declarations, the raw generated API reflects Rust: snake_case function names unless renamed, free() methods on classes, an init function that must be awaited first. Many teams publish a thin hand-written TypeScript module on top of the generated package — the facade — and treat the wasm-bindgen output as an implementation detail. The facade exports idiomatic names, hides initialisation behind lazy loading, wraps class handles with Symbol.dispose, and converts errors into documented types. Its types are written by hand once, are small, and change only when the public API changes; the generated declarations underneath can then be pragmatic rather than perfect. This split also isolates consumers from wasm-bindgen upgrades, which occasionally change generated names or signatures. If you take this route, the attributes on this page still matter, because they make the facade’s own implementation type-checked against the generated layer, catching mismatches between the two at compile time rather than in production.

Verifying the declarations

Types that are promises — unchecked_into, custom sections — can be wrong without anything failing at build time. Add a type-level test to the JavaScript side: a small .ts file in the consuming project (or a types.test-d.ts with tsd or expect-type) that calls each function with correctly typed arguments, asserts the inferred return types, and includes // @ts-expect-error lines for calls that must be rejected, such as a missing required field. Compile it with tsc --noEmit in CI. Pair it with a runtime test that calls the same functions and checks the shape of the results against the declared interfaces, so that a serde attribute change that alters the wire format — a removed rename_all, a field made optional — fails the build rather than surfacing as a runtime undefined in production. These checks are short and catch the class of bug that typed bindings otherwise invite: declarations that look precise but describe a different shape from what the code produces.

Expected output

The generated .d.ts declares search(options: SearchOptions): Hit[] and encode(frames: Uint8Array, on_progress: ProgressCallback): void; an editor autocompletes maxResults; calling search({ query: 1 }) is a compile error; and the type test passes in CI.

Gotchas

  • Declarations that lie. unchecked_into and custom sections are not verified. Test the runtime shape.
  • Forgetting serde renames. max_results in Rust is maxResults in JavaScript. Match the declarations to the wire format.
  • Option versus optional properties. None serialises to undefined; declare such fields optional.
  • Duplicated interface names. Two crates declaring Options collide in bundled typings. Use specific names.
  • Old wasm-bindgen versions. unchecked_param_type and unchecked_return_type need a recent release; fall back to extern types.

Performance note

None of these attributes changes the generated JavaScript or Wasm. In a test crate with 40 exported functions, enabling tsify added no bytes to the .wasm and 6 KB to the uncompressed .d.ts; build time increased by about 2 seconds from the derive macros.

Untyped parameters and returns in a 40-function API Number of parameters and return values typed as any in the generated declarations, with default output, with hand-written custom sections, and with tsify-derived declarations. count of any-typed parameters and returns defaults 37 custom section + typescript_type 4 tsify derive 0

Frequently Asked Questions

Do these attributes affect runtime behaviour? No. They only change the .d.ts file. Runtime conversions are still done by the glue and serde.

Can I post-process the .d.ts instead? You can, but edits are lost on every build. Attributes keep the source of truth in Rust.

Does wasm-pack pass these through? Yes — wasm-pack runs wasm-bindgen, so its output includes the custom declarations.

How do I type a function returning a Promise? Async exports are declared as Promise<any>; use unchecked_return_type = "Promise<Hit[]>" to refine them.

Can tsify handle enums? Yes, including internally tagged ones, which become TypeScript discriminated unions.

Can I rename exported functions to camelCase? Yes — #[wasm_bindgen(js_name = searchIndex)] changes the JavaScript name and the declaration together.

← Back to wasm-bindgen Deep Dive