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.tsfile. - [ ] 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.
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.
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_intoand custom sections are not verified. Test the runtime shape. - Forgetting serde renames.
max_resultsin Rust ismaxResultsin JavaScript. Match the declarations to the wire format. Optionversus optional properties.Noneserialises toundefined; declare such fields optional.- Duplicated interface names. Two crates declaring
Optionscollide in bundled typings. Use specific names. - Old wasm-bindgen versions.
unchecked_param_typeandunchecked_return_typeneed 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.
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.
Related
- Serializing data with serde-wasm-bindgen — the conversions being typed.
- Reading the glue code wasm-bindgen generates — what sits behind the declarations.
- Throwing JavaScript exceptions from Rust — documenting thrown errors.
- Exporting Rust structs as JavaScript classes — classes with precise types by default.
← Back to wasm-bindgen Deep Dive