A proc-macro that generates TypeScript type aliases and wasm-bindgen ABI trait
implementations for Rust callback wrapper structs, enabling strongly-typed
Typescript Functions types in ts-macro projects with little to no boilerplate.
When using ts-macro to generate
TypeScript interfaces for Rust structs, there is no built-in support for
function or callback types. A struct field like on_click: js_sys::Function
produces an opaque Function type in TypeScript and forces you to manually
handle raw JsValue conversions in Rust.
ts-function bridges this gap. It allows you to define callback signatures in
pure Rust, automatically generating the correct TypeScript types ((args: ...) => void) and implementing the wasm-bindgen ABI traits required to pass those
callbacks across the Wasm boundary safely.
This pattern which in my opinion, is a good way to hook a Rust WASM App like into a Typescript codebase, Is the modivating use case for this project. Specifically, I wanted to do this for a Bevy project of mine, embeded inside a React frontend. Something I found to work well was a pattern where rust code called callbacks which modify valtio state, causing React to re-render. (Also, for the record I would 100% have chosen something more "interesting" than React, but this is a work project)
use wasm_bindgen::prelude::*;
use ts_function::ts_function;
use ts_macro::ts;
// 1. Define your callback signatures in pure Rust using type aliases
#[ts_function]
pub type SingleArgCb = fn(msg: String);
#[ts_function]
pub type MultiArgCb = fn(a: f64, b: js_sys::Uint8Array);
// 2. Use `ts_macro` to create a struct containing your callbacks
#[ts]
struct AppCallbacks {
on_ready: SingleArgCb,
on_data: MultiArgCb,
}
// 3. Export a function that accepts your callbacks from JS
#[wasm_bindgen]
pub fn execute_callbacks(cbs: IAppCallbacks) {
// `.parse()` safely extracts the `js_sys::Function` objects into your strongly-typed wrappers
let callbacks: AppCallbacks = cbs.parse();
// Call the functions! Arguments are automatically converted to `JsValue`.
// The `call` method returns a `Result<(), JsValue>` forwarding any JS exceptions.
callbacks.on_ready.call("System is ready".to_string()).unwrap();
let arr = js_sys::Uint8Array::new_with_length(3);
arr.copy_from(&[1, 2, 3]);
callbacks.on_data.call(42.5, arr).unwrap();
}wasm-bindgen outputs the types you would expect,
using the wonderful ts-type crate,
meaning zero #ts[(type = "...")] manual overrides are required.
/* tslint:disable */
/* eslint-disable */
// The type aliases generated by `#[ts_function]`
type SingleArgCb = (msg: string) => void;
type MultiArgCb = (a: number, b: Uint8Array) => void;
// The interface generated by `#[ts]` in `ts-macro`
interface IAppCallbacks {
onReady: SingleArgCb;
onData: MultiArgCb;
}
export function execute_callbacks(cbs: IAppCallbacks): void;ts-function supports a wide range of Rust types, automatically mapping them to their idiomatic TypeScript equivalents.
| Rust Type | TypeScript Type | Notes |
|---|---|---|
f32, f64, i8-i32, u8-u32 |
number |
|
i64, u64 |
bigint |
Mapped via js_sys::BigInt |
bool |
boolean |
|
String, &str |
string |
|
Option<T> |
T | undefined |
|
Vec<u8>, &[u8] |
Uint8Array |
See Zero-Copy Performance Tip |
Vec<f64>, &[f64] |
Float64Array |
Also supports i8, u16, i32, etc. |
JsValue |
any |
|
js_sys::Object |
object |
|
IMyInterface |
IMyInterface |
Any JsCast type (including ts-macro interfaces) |
The #[ts_function] macro supports returning values from JavaScript back into
Rust. It handles standard primitives, strings, options, and even numeric vectors
(via TypedArray).
#[ts_function]
pub type CalculateCb = fn(a: f64) -> f64;
// Usage:
let result: f64 = calculate_cb.call(10.0).unwrap();When returning large arrays, using Vec<u8> or similar will force a memory copy
from the JavaScript heap to the Rust heap. To achieve zero-copy performance,
you can return a js_sys type directly:
#[ts_function]
pub type LargeDataCb = fn() -> js_sys::Uint8Array;
// Usage (Zero-Copy):
let arr: js_sys::Uint8Array = large_data_cb.call().unwrap();The macro uses JsCast::unchecked_into for these types, allowing you to work
directly with the JavaScript memory view.
By relying on the generated TypeScript signatures, ts-function avoids some of
the runtime overhead typically associated with JsValue dynamic type checking.
Because the TypeScript compiler ensures that the JavaScript environment satisfies
the function's contract, we can use unchecked casts in the generated Rust glue
code.
Now that ts-function natively supports returning values and returning Result<T, JsValue> for errors,
the primary API via type aliases (pub type MyCb = fn(...) -> ...) handles almost all use cases.
However, if you need completely custom serialization, have complex types not supported natively by the macro,
or want to embed specific side-effects and error handling directly into the callback execution, you can use the escape hatch.
By applying #[ts_function] to an impl block for a tuple-struct wrapping js_sys::Function, you take complete control of the call method's implementation.
ts-function will still parse your call method's signature and automatically generate the correct TypeScript interface for it!
use wasm_bindgen::prelude::*;
use ts_function::ts_function;
// 1. Define your own tuple struct
pub struct CustomLoggingCallback(pub js_sys::Function);
// 2. Apply the macro to the impl block
#[ts_function]
impl CustomLoggingCallback {
// 3. Define the `call` signature exactly how you want it exposed to TypeScript
pub fn call(&self, val: f64) {
// You are responsible for all conversions and calling the JS function
let result = self.0.call1(
&wasm_bindgen::JsValue::NULL,
&wasm_bindgen::JsValue::from_f64(val),
);
// Handle errors internally however you want
if let Err(e) = result {
if let Ok(err_obj) = e.dyn_into::<js_sys::Error>() {
web_sys::console::error_1(&err_obj.message().into());
}
}
}
}In the example above, ts-function will emit export type CustomLoggingCallback = (val: number) => void;.
Dual-licensed under either the MIT license or the Apache License, Version 2.0 at your option.