๐ Tsain: Chain TS & RS in Fast and Secure Way
Tsain carries Rust types across the wasm-bindgen boundary as positional arrays instead of named objects. One idea, two payoffs:
- Fast โ dropping field and variant names makes ser/de faster than
serde-wasm-bindgen. - Secure โ names never appear in the emitted JS, so (with mangling) your interface stays hidden.
It is meant for Rust โ WASM projects that already use wasm-bindgen.
Concretely, Tsain does two things, both built on that array format:
-
Serde between
rust&JsValue. It builds onserde-wasm-bindgen's serde approach, buttsainserializes structs and enums into an Array format. This prevents field and variant names from being revealed in the resulting JS file. -
Export a TypeScript file holding tsain's exported types and the factory functions that construct them. It follows
Tsify's format, but structs, enums, and the like use tsain's derived Array format.
These are not two separate features โ they are two views of one thing. A Rust type has a single canonical array shape; ser/de reads and writes that shape at runtime, and the TS export describes the same shape at the type level so JS can build and read it safely.
โโ runtime value: e.g. [1, 2] โ ยง1 (serde)
Point { x, y } โโโบ [number, number] โโค
โโ TypeScript: type Point = [number, number]
+ Point_() factory & getters โ ยง2 (export)
Getting Started
// Rust
use *;
use ;
use *;
// Pass tsain types across the wasm boundary with `Ts<T>`.
// Emit the matching TypeScript. Run it with `cargo test`
// Exported TS file
// Point Type
export type Point = [number, number] & { readonly __brand: "Point" };
// Point Constructor
export const Point_ = (x: number, y: number) => [x, y] as Point;
// Point Getters: Point_x, Point_y (...)
1. At runtime โ serde between rust & JsValue
This is the canonical array shape seen at runtime: a value is written to / read from its array form.
Rust to JsValue: tsain::to_value
use *;
use *;
use ;
let point = Point ;
let js_value: JsValue = to_value.unwrap;
JsValue to Rust: tsain::from_value
let js_value: JsValue = of2.into;
let point: Point = from_value.unwrap;
Crossing the #[wasm_bindgen] boundary: Ts<T>
Ts<T> is the runtime conversion above, wrapped for the wasm-bindgen ABI: it derives IntoWasmAbi and FromWasmAbi so tsain types can be passed directly to and from #[wasm_bindgen] functions, instead of manually calling to_value/from_value on a JsValue. This is a straight import of Tsify's Ts<T>.
use *;
use *;
Conversion formats
-
Struct <=> [...struct's fields]
// <=> [string, Point[]] -
Enum <=> [variant-number, [...variant's fields]]
A unit variant is serialized from Rust as
[0], while the exported TS constructor builds[0, []]; deserialization accepts both, since the payload of a unit variant is ignored. -
Other types: follow serde-wasm-bindgen
2. At the type level โ export TS types and factory functions
This is the same array shape described in TypeScript, so JS code can build and read it without ever touching field names.
The Tsain proc macro builds a TS script of the Tsain-derived types and the factory functions that construct them.
Because tsain uses the "Array format" by default, without field or variant names, users need a factory function to construct the TS type.
Unlike Tsify, Tsain does not insert types into the .d.ts file; instead, it encourages users to use tsain::TsScript to explicitly write a .ts file.
(This is because tsain exports not only types but also some actual functions: currently it is not possible to insert functions into wasm-pack built files.)
tsain also exports each TS type with a TypeScript "brand pattern" so they can be distinguished from one another. (For example, this makes it possible to distinguish different enum types that actually share the same JS type.) The brand pattern can be controlled with attributes. Only struct and enum-variant export types have a brand pattern; the enum type itself does not (and should not).
Use tsain::TsScript to write TS file
- When using it inside a lib crate, use
cargo testto run the script building.
// lib.rs
โ Example1: Struct
use Tsain;
use ;
โก๏ธ Declare TS (the same Point from ยง1, now described at the type level):
// 1. Type: by default, tsain adds a brand pattern (use `skip_brand` attribute to omit it)
export type Point = [number, number] & { readonly __brand: "Point" };
// 2. Factory function: named as `{TypeName}_`
export const Point_ = (x: number, y: number) => [x, y] as Point;
// 3. Getters
export const Point_x_id: number = 0;
export const Point_x = (v: Point): number => v[0];
export const Point_y_id: number = 1;
export const Point_y = (v: Point): number => v[1];
โ Example2: Enum
use Tsain;
โก๏ธ Declare TS:
// 1. Enum Union Type
export type Vessel = VesselOrigin | VesselOilTanker | VesselBoat | Pirogue;
// 2. Var Ids
export const VesselOriginId: number = 0;
export const VesselOilTankerId: number = 1;
export const VesselBoatId: number = 3;
export const PirogueId: number = 4;
// 3. As Each Var
export const asVesselOrigin = (e: Vessel): VesselOrigin | undefined =>
e[0] == 0 ? (e as VesselOrigin) : undefined;
export const asVesselOilTanker = (e: Vessel): VesselOilTanker | undefined =>
e[0] == 1 ? (e as VesselOilTanker) : undefined;
export const asVesselBoat = (e: Vessel): VesselBoat | undefined =>
e[0] == 3 ? (e as VesselBoat) : undefined;
export const asPirogue = (e: Vessel): Pirogue | undefined =>
e[0] == 4 ? (e as Pirogue) : undefined;
// 3. Each Vars
// Variant 0: VesselOrigin
// 1. Type
export type VesselOrigin = [0, [Point]] & { readonly __brand: "VesselOrigin" };
// 2. Constructor
export const VesselOrigin_ = (f0: Point) => [0, [f0]] as VesselOrigin;
// 3. Getters
export const VesselOrigin_f0_id: number = 0;
export const VesselOrigin_f0 = (v: VesselOrigin): Point => v[1][0];
// Variant 1: VesselOilTanker
// 1. Type
export type VesselOilTanker = [1, []] & { readonly __brand: "VesselOilTanker" };
// 2. Constructor
export const VesselOilTanker_ = () => [1, []] as VesselOilTanker;
// Variant 3: VesselBoat
// 1. Type
export type VesselBoat = [3, [string, number[]]] & {
readonly __brand: "VesselBoat";
};
// 2. Constructor
export const VesselBoat_ = (name: string, capacity: number[]) =>
[3, [name, capacity]] as VesselBoat;
// 3. Getters
export const VesselBoat_name_id: number = 0;
export const VesselBoat_name = (v: VesselBoat): string => v[1][0];
export const VesselBoat_capacity_id: number = 1;
export const VesselBoat_capacity = (v: VesselBoat): number[] => v[1][1];
// Variant 4: Pirogue โ name overridden via #[tsain(name = Pirogue)]
// 1. Type
export type Pirogue = [4, [string]] & { readonly __brand: "Pirogue" };
// 2. Constructor
export const Pirogue_ = (name: string) => [4, [name]] as Pirogue;
// 3. Getters
export const Pirogue_name_id: number = 0;
export const Pirogue_name = (v: Pirogue): string => v[1][0];
โ Example3: Generic Types
#[derive(Tsain)] works directly on generic structs and enums:
use *;
use ;
โก๏ธ Declare TS:
// # Struct Page
export type Page<T> = [T[], number] & { readonly __brand: "Page" };
export const Page_ = <T>(items: T[], total: number) => [items, total] as Page<T>;
// (...)
// # Enum Status
export type Status<T> = StatusOk<T> | StatusErr;
export type StatusOk<T> = [0, [T]] & { readonly __brand: "StatusOk" };
export const StatusOk_ = <T>(f0: T) => [0, [f0]] as StatusOk<T>;
export type StatusErr = [1, [string]] & { readonly __brand: "StatusErr" };
export const StatusErr_ = (f0: string) => [1, [f0]] as StatusErr;
Use tsain! macro for Type Alias
tsain! exports type aliases โ plain, generic, or pointing to a generic type. Multiple aliases can go in one block:
tsain!
โก๏ธ Declare TS:
export type Cup = [string, number];
export type Pair<A, B> = [A, B[]];
export type PageOfStatus = Page<Status<number>>;
Attributes of #[tsain(..)]
-
#[tsain(name = OtherName)]Override export type name
-
#[tsain(skip_brand)]Omit a default brand pattern of
__brand.Works only for struct or enum-variant.
-
#[tsain(brand(ret = Vec<u32>, msg = "Message"))]Add extra brand patterns, allows Type or string-literal as values. Above attribute will add:
{ readonly ret: number[], readonly msg: "Message" }Works only for struct or enum-variant.
More Tips
Use #[serde(expecting = "x")] when deriving Serde
This will diminish wasm's data section messages a lot!
Why tsain is Fast & Secure ๐
Tsain reuses serde-wasm-bindgen for primitive types, so in a sense it is a version of serde that combines the philosophies of serde-wasm-bindgen and postcard.
- Fast โ bench tests show Tsain is much faster than
serde-wasm-bindgenat ser/de, thanks to omitting field names when encoding/decoding. For primitive types without field names, performance is basically equal toserde-wasm-bindgen. - Secure โ with proper mangling, the original names of types, functions, and properties are not exposed in the emitted JS. This was the very first motivation for developing Tsain.