Data manipulation and hair-loss with WASM Bindgen

4th Oct 2023

I've been trying to move some of the Soniq application logic into WebAssembly using wasm-pack. I'm not sure whether it makes total sense at the moment, but I figure, the best way to find out is to do it and analyse the results.

I have been building an invoicing section which will take a set of lessons and create the corresponding invoices. It's a relatively clean operation, I can feed in a load of lessons and contacts and it should respond with a list of invoices and invoice lines.

Defining a struct from my JSON Schema

I started by implementing a function which would return a result of the invoice schema. This meant that I had to define the schema itself. After some searching I came across the schemafy crate. It includes the schemafy! macro which will take a JSON Schema document and generate the corresponding Rust types serialisable with serde.

use serde::{Deserialize, Serialize};
schemafy::schemafy!(
root: InvoiceSchema
"src/invoice/Invoice.schema.json"
);

Coming from TypeScript, I find these macros particularly magical. I would have had to use a separate script with a corresponding file watcher (or manual run) to accomplish the same task. It will be interesting to see if there are any local development performance implications.

I have this schema in a local crate called soniq-schema-payments so it needs to be made public.

pub use schema::InvoiceSchema;
mod schema;

Then in the root lib.rs I mocked out a wasm function which returned an instance of this.

#[wasm_bindgen]
pub fn inv() -> Result<InvoiceSchema, Box<dyn std::error::Error>> {
let invoice = InvoiceSchema {
total: 10.0,
bill_to_email: String::from("d@d.com"),
bill_to_contact_id: String::from("contact"),
bill_to_name: String::from("name"),
created_at: String::from("2022-01-01T10:00:00Z"),
created_by: String::from("foo"),
currency: String::from("GBP"),
due_date: String::from("2022-01-01T10:00:00Z"),
status: String::from("draft"),
message: Some(String::from("hello")),
meta: None
};
Ok(invoice)
}

I was peeling the onion, next I worked my way through a whole raft of errors because wasm-bindgen wasn't able to pass this data back to the JavaScript.

Passing arbitrary data between the Rust code and JavaScript.

I had a local type based on my Invoice JSON Schema. I was able to create a mock object but now I couldn't compile because the types could not be sent back to the JavaScript code.

There were a whole raft of errors, depending on what I changed, many of them referenced traits from wasm_bindgen like the following:

error[E0277]: the trait bound `Result<InvoiceSchema, i32>: ReturnWasmAbi` is not satisfied
--> src/lib.rs:24:1
|
24 | #[wasm_bindgen]
| ^^^^^^^^^^^^^^^ the trait `ReturnWasmAbi` is not implemented for `Result<InvoiceSchema, i32>`
|
= help: the trait `ReturnWasmAbi` is implemented for `Result<T, E>`
= note: this error originates in the attribute macro `wasm_bindgen` (in Nightly builds, run with -Z macro-backtrace for more info)

Finally I found the documentation explaining what I was trying to achieve: Arbitrary data with serde. To summarise, the return type is a JsValue and we use the serde_wasm_bindgen crate to convert the InvoiceSchema to JsValue.

#[wasm_bindgen]
pub fn inv() -> JsValue {
let invoice = InvoiceSchema {
total: 10.0,
bill_to_email: String::from("d@d.com"),
bill_to_contact_id: String::from("contact"),
bill_to_name: String::from("name"),
created_at: String::from("2022-01-01T10:00:00Z"),
created_by: String::from("foo"),
currency: String::from("GBP"),
due_date: String::from("2022-01-01T10:00:00Z"),
status: String::from("draft"),
message: Some(String::from("hello")),
meta: None
};
// InvResult { invoice }
serde_wasm_bindgen::to_value(&invoice).unwrap()
}

Finally I got a passing wasm-pack build passing. I was left a little underwhelmed by the lack of type safety in the final result now that the return value was JsValue, which is compiled to a TypeScript any.

In Rust, you can't inherit from another Struct and add extra properties

The tale continues, I find myself a little further down the track with a few other schemas and the beginnings of my new function. It was then I hit the next learning opportunity 🧐. My JSON schemas all validate the raw data and don't include id properties. I then have a TypeScript type called SchemaWithId which includes the id property in a new type.

So how should I implement the same thing in Rust? Well, I can't, in Rust you can't inherit from another struct and add extra properties. My first step was to accept there was some good, underlying reason for this language design decision and embrace it.

I wonder if there is a more DRY was of implementing this, but for the moment I have settled on defining the *WithId structs like this:

#[derive(Serialize, Deserialize)]
pub struct InvoiceWithId {
pub id: String,
pub schema: InvoiceSchema,
}

I am using serde here to allow the struct to be serialised/deserialised but I was curious whether there was some ability to serialise the object into the flattened shape I needed in my TypeScript code. I wrote up a Stackoverflow question to see what would come up: Flatten Rust struct with extra id property back to TypeScript single object in return of wasm-bindgen function. Take a quick look, it summarises this problem quite succinctly.

Thanks to cdhowie, I was shown the #[serde(flatten)] macro which got me 90% there. It flattens the property into the parent object, although wasm_bindgen is then returning it to JavaScript as a Map

#[derive(serde::Serialize)]
#[serde(rename_all = "camelCase")]
struct InvoiceSchema {
pub bill_to: String,
pub email: String,
}
#[derive(serde::Serialize)]
struct InvoiceWithId {
pub id: String,
#[serde(flatten)]
pub schema: InvoiceSchema,
}
#[wasm_bindgen]
fn get_invoice_with_id() -> JsValue {
let invoice_with_id = InvoiceWithId { ... };
serde_wasm_bindgen::to_value(&invoice_with_id).unwrap()
}

Stumbled upon tsify

This article is getting long, I will make this the last section although I am by no means out of the woods yet.

In all the research and exploration to resolve the problems I have had up to this point, I happened to stumble upon the tsify crate which has improved things further.

It first of all, generates TypeScript types from Rust code as well all implementing the WasmAbi traits to allow my bindgen code to accept and return Rust types rather than JsValue.

My InvoiceWithId struct now looks like this:

#[derive(Tsify, Serialize, Deserialize)]
#[tsify(into_wasm_abi, from_wasm_abi)]
pub struct InvoiceWithId {
pub id: String,
#[serde(flatten)]
pub schema: InvoiceSchema,
}

Again, not quite there yet ... I have bumped up against the deserialising from an array of the type. Hey, hey, two steps forward and one step back still feels like progress.