Back to blog

1/19/2024

Working With Rust, TypeScript and WebAssembly Made a Bit Easier

Using wasm-bindgen, we can put the wasm_bindgen macro on top of something we want to make accessible from WASM.

#[wasm_bindgen]pub fn fib(n: u32) -> u32 {    if n < 2 {        1    } else {        fib(n - 1) + fib(n - 2)    }}

We build this using wasm-pack on wasm32-* toolchain, which generates TypeScript definitions and a JavaScript file for loading WASM modules, ABI conversions and memory management.

RUSTFLAGS="-C opt-level=s" \  wasm-pack build --mode no-install \  --out-name "wasm" \  --out-dir "./pkg/node" \  --target nodejs \  --no-pack \  --release

The target argument depends on whether we want to run it in a Node.js environment or in the browser. We can build both and import as needed.

export function fib(n: number): number;// ...import * as wasm from "./pkg/node/wasm";expect(wasm.fib(5)).toBe(8);for (let i = 0; i < 10; i++) {    console.log(wasm.fib(i));}

Seems to be working.

bun testscript.test.ts:11235813213455✓ wasm > fib [1.02ms]  1 pass  0 fail

Let's try a struct type.

#[derive(Debug)]#[wasm_bindgen(getter_with_clone, inspectable)]pub struct Task {    pub id: u32,    pub description: String,    pub done: bool,}

This is the generated TypeScript definition.

export class Task {    /**      * @param {number} id      * @param {string} description      */    constructor(id: number, description: string);    id: number;    // ...    toJSON(): Object;    toString(): string;}const task = new wasm.Task(42, "write a blog post");expect(task.id).toBe(42);expect(task.description).toBe("write a blog post");

Adding inspectable argument gives us toJSON and toString methods. Because fields are public and need to bo copyable, we can add getter_with_clone argument to generate getters that return cloned values.

The problem

Let's try an enum.

#[wasm_bindgen]pub enum InvalidEnum {    Message(String),    Status(u32),}

This throws an error:

only C-Style enums allowed with #[wasm_bindgen]

Turns out, not all types are supported. This posses a problem.

I need to handle enums and I don't want to use JavaScript classes. What I needed was to utilize the same types as generated by my gRPC/grpc-web services. I also want to only import TypeScript types, without the .wasm module, and use them as "plain JS objects" in state stores. I don't want a class, I want a plain object which I can also pass to any WASM function. If you're retrieving an object from a service call, it's annoying to wrap them in a class instance every time you want to use them as with WASM.

I also want to declare newtypes such as struct Id(u128), with the possibility to convert them from and into strings.

This is the output for a newtype:

export class Id {  free(): void;  0: bigint;}

But, I want this:

export type Id = string;

The conversion would be done with FromStr and ToString traits.

The solution

The solutions is to use serde and serde-wasm-bindgen crate. We prepare types for WASM interoperability the same way as for serialization, which includes enum tags, transparent newtypes and custom serialization functions.

Doing this is not as efficient, but we can always revert back to only using wasm-bindgen for specific cases.

We still need to generate .d.ts type definitions, conversions to ABI/JsValue and calls to serde_wasm_bindgen.

So, I've written a macro that does that in my multi-purpose Bomboni library.

Here's an example of adjacently tagged enum:

#[derive(Debug, Clone, Serialize, Deserialize, Wasm)]#[serde(tag = "type", content = "value", rename_all = "SCREAMING_SNAKE_CASE")]#[wasm(wasm_abi)]pub enum Value {    String(String),    Number(f64),    Reference { column: u32, row: u32 },}

which generates the following TypeScript definition:

export type Value =  | {        type: "STRING";        value: string;    }  | {        type: "NUMBER";        value: number;    }  | {        type: "REFERENCE";        value: {            column: number;            row: number;        };    };

You need to derive Wasm macro on some type, then add wasm(wasm_abi) attribute to generate ABI conversions. By itself, it only generates typescript definitions.

Here's another example:

/*export interface Cell {    id: [number, number];    value: Value;}*/#[derive(Debug, Clone, Serialize, Deserialize, Wasm)]#[wasm(wasm_abi)]pub struct Cell {    pub id: (u32, u32),    pub value: Value,}// export type Grid = Cell[][];#[derive(Debug, Serialize, Deserialize, Wasm)]#[wasm(wasm_abi)]pub struct Grid(Vec<Vec<Cell>>);// export type ItemId = string;#[derive(Debug, Serialize, Deserialize, Wasm)]#[wasm(as_string)]pub struct ItemId(u128);

It also supports using "proxy" types, for when you want WASM interop based on serialization of another type.

Here, I'm using DataType type, generated by tonic-build (gRPC crate), to handle WASM interop with "easier to use" ParsedDataType type.

#[derive(Debug, Clone, PartialEq, Parse)]#[parse(source = DataType, write)]#[cfg_attr(    all(        target_family = "wasm",        not(any(target_os = "emscripten", target_os = "wasi")),        feature = "wasm"    ),    derive(bomboni::wasm::Wasm),    wasm(        bomboni_wasm = bomboni::wasm,        proxy { source = DataType, try_from = RequestParse::parse },        wasm_abi,    ),)]pub struct ParsedDataType {  // ...}

Conclusion

Examples are on GitHub.

Bomboni library is still WIP. I'm using it and adding to it whatever I need for my other projects.

Follow me on X/Twitter!

Subscribe to our newsletter

Join our newsletter for regular updates. No spam ever.