tsain-core 0.1.0

Chain TypeScript and Rust in fast and secure way
Documentation

๐Ÿ”— 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:

  1. Serde between rust & JsValue. It builds on serde-wasm-bindgen's serde approach, but tsain serializes structs and enums into an Array format. This prevents field and variant names from being revealed in the resulting JS file.

  2. 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

cargo add tsain serde wasm-bindgen

// Rust
use tsain::*;
use serde::{Serialize, Deserialize};
use wasm_bindgen::prelude::*;

#[derive(Tsain, Serialize, Deserialize)]
pub struct Point {
    x: i32,
    y: i32,
}

// Pass tsain types across the wasm boundary with `Ts<T>`.
#[wasm_bindgen]
pub fn make_point() -> TsainResult<Ts<Point>> {
    Ts::from_rust(&Point { x: 1, y: 2 })
}

#[wasm_bindgen]
pub fn use_point(point: Ts<Point>) -> TsainResult<()> {
    let _point: Point = point.to_rust()?;
    Ok(())
}

// Emit the matching TypeScript. Run it with `cargo test`
#[test]
fn export_ts() {
    tsain::TsScript::export("./Tsain.ts");
}
// 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 tsain::*;
use wasm_bindgen::prelude::*;
use serde::{Serialize, Deserialize};

#[derive(Serialize, Deserialize)]
pub struct Point {
    x: i32,
    y: i32,
    #[serde(skip)] // This field will be skipped
    z: i32,
}

let point = Point { x: 10i32, y: 20i32, z: 30i32 };
let js_value: JsValue = tsain::to_value(&point).unwrap();

JsValue to Rust: tsain::from_value

let js_value: JsValue = js_sys::Array::of2(&10i32.into(), &20i32.into()).into();
let point: Point = tsain::from_value(js_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 tsain::*;
use wasm_bindgen::prelude::*;

#[derive(Tsain, Serialize, Deserialize)]
pub struct Point {
    x: i32,
    y: i32,
}

#[wasm_bindgen]
pub fn into_js() -> TsainResult<Ts<Point>> {
    let point = Point { x: 0, y: 0 };
    let x = Ts::from_rust(&point)?;
    Ok(x)
}

#[wasm_bindgen]
pub fn from_js(point: Ts<Point>) -> TsainResult<()> {
    let _point: Point = point.to_rust()?;
    Ok(())
}

Conversion formats

  • Struct <=> [...struct's fields]

    struct Example { a: String, b: Vec<Point> } // <=> [string, Point[]]
    
  • Enum <=> [variant-number, [...variant's fields]]

    enum Example {
        First, // <=> `[0]` (unit variant: no payload)
        Second(String, Vec<Point>), // <=> `[1, [string, Point[]]]`
        Third { a: u32, b: i32, } // <=> `[2, [number, number]]`
    }
    

    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 test to run the script building.
// lib.rs
#[test]
fn export_ts() {
    // Option1: Export all types with Tsain macro derived
    tsain::TsScript::export("./Tsain.ts");

    // Option2: Export listed types
    tsain::TsScript::new()
        .with_comment("export const X: number = 10;\n\n")  // comments
        .with_exclusive(vec!["Point"]) // exclusive list: only includes these types when the list is not empty.
        .with_exclude(vec!["Vessel"]) // exclude any types in this list
        .export_script("./Tsain2.ts");
}

โœ… Example1: Struct

use tsain::Tsain;
use serde::{Serialize, Deserialize};

#[derive(Tsain, Serialize, Deserialize)]
struct Point {
    x: i32,
    y: i32,
    #[serde(skip)] // This field will be skipped
    z: i32,
}

โžก๏ธ 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::Tsain;

#[derive(Tsain, Serialize, Deserialize)]
enum Vessel {
    // Internal types are supposed to impl Tsain too
    Origin(Point),
    OilTanker,
    #[serde(skip)] // This variant will be skipped
    WarShip,
    Boat {
        name: String,
        capacity: Vec<usize>,
        #[serde(skip)] // This field will be skipped
        uid: String,
        color: String,
    },
    // override variant's TS name
    #[tsain(name = Pirogue)]
    Canoe { name: String },
}

โžก๏ธ 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 tsain::*;
use serde::{Serialize, Deserialize};

#[derive(Tsain, Serialize, Deserialize)]
struct Page<T> {
    items: Vec<T>,
    total: u32,
}

#[derive(Tsain, Serialize, Deserialize)]
enum Status<T> {
    Ok(T),
    Err(String),
}

โžก๏ธ 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! {
    // plain tuple alias
    pub type Cup = (String, u32);
    // generic alias
    pub type Pair<A, B> = (A, Vec<B>);
    // concrete alias pointing to a generic type
    pub type PageOfStatus = Page<Status<u32>>;
}

โžก๏ธ 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-bindgen at ser/de, thanks to omitting field names when encoding/decoding. For primitive types without field names, performance is basically equal to serde-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.