WST is a language for defining types that compile to TypeScript and Rust.
From source (requires Rust):
git clone https://github.com/yanvah/wst
cd wst
cargo install --path .
# Validate only (no output written)
wst -i schema.wst
# Compile a single file to TypeScript
wst -i schema.wst -f ts -o schema.ts
# Compile a single file to Rust
wst -i schema.wst -f rust -o schema.rs
# Compile a directory of .wst files
wst -i ./types/ -f ts -o ./generated/
| Flag | Output |
|---|---|
-f ts |
TypeScript (.ts) |
-f rust |
Rust (.rs) |
-f json |
JSON AST (default) |
Syntax highlighting and inline error checking are available via the VSCode Marketplace.
cargo testMIT — see LICENSE.
| WST | TypeScript | Rust |
|---|---|---|
int32 |
number |
i32 |
int64 |
number |
i64 |
uin64 |
number |
u64 |
flt64 |
number |
f64 |
boolean |
boolean |
bool |
string |
string |
String |
| WST | TypeScript | Rust |
|---|---|---|
vec<T> |
T[] |
Vec<T> |
map<P, T> |
Record<P, T> |
HashMap<P, T> |
Map keys (P) must be a primitive type — primitives have well-defined equality and hashing semantics.
Enums define a fixed set of named values with no associated data.
enum Direction {
North,
South,
East,
West,
}
TypeScript output:
export enum Direction {
North = "North",
South = "South",
East = "East",
West = "West",
}
Rust output:
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum Direction {
North,
South,
East,
West,
}
Variants are tagged unions — each case carries an associated type. Use a variant when different cases need to hold different data.
variant Result {
Ok = string,
Err = int32,
}
TypeScript output:
export type Result =
| { Ok: string }
| { Err: number }
;
Rust output:
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum Result {
Ok(String),
Err(i32),
}
struct Point {
x = flt64 #required,
y = flt64 #required,
label = string,
}
TypeScript output:
export interface Point {
x: number;
y: number;
label?: string | null;
}
Rust output:
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Point {
pub x: f64,
pub y: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
}
Tags annotate fields and cases with metadata. They appear after the field name or type.
| Tag | Meaning |
|---|---|
#required |
Field must be present (struct fields only) |
#optional |
Explicitly marks a field optional — the default, so rarely needed |
#nullable |
Field value may be null (see Nullability) |
#deprecated |
Field or case is deprecated |
#banned |
Field is banned; emits never in TypeScript, #[deprecated] in Rust |
Custom tags require a namespace prefix to prevent collision with built-in tags.
struct Foo {
id = int64 #required #myorg:indexed,
score = flt64 #myorg:precision=2,
note = string #myorg:description="user-visible note",
}
Tags default to true when no value is given. Supported value types: string (""), number, boolean.
Use [...] to expand tags across multiple lines.
struct Foo {
score = flt64 [
#required
#nullable
#myorg:precision=2
],
}
protocol Api {
"/create" [
#myorg:auth=true
#myorg:version=2
] <Request, Response !Error>,
}
Tags can appear on any definition — enum, variant, struct, or protocol — between the name and the opening {.
struct MyStruct #myorg:since="v2" {
id = int64 #required,
}
enum Status [
#myorg:codegen:exhaustive
] {
Active,
Inactive,
}
Definition-level tags follow the same inline/multi-line rules. Built-in struct-only tags (#required, #optional) are not valid at definition level.
Protocols define typed endpoints or RPCs.
protocol UserApi {
"/users/get" <GetRequest, User !ApiError>,
"/users/create" <CreateRequest, User !ApiError>,
"/users/list" <ListRequest, vec<User>>,
}
The !ErrorType slot specifies the typed error for an endpoint. Omit it for endpoints that don’t return typed errors.
Prefix any definition with private to exclude it from exports and prevent it from being imported.
private struct InternalToken {
value = string #required,
}
private enum InternalStatus { Ok, Fail }
Private types still participate in copy resolution and assertions within the same file.
Enforcers are file-level directives declared at the top of a file. They impose constraints on all definitions within it.
!optional_mode=implicit;
!optional_modeControls how optional (non-#required) struct fields are typed in TypeScript output.
| Value | TypeScript output for optional fields |
|---|---|
implicit (default) |
field?: T \| null |
explicit |
field: T \| null |
!optional_mode=explicit;
struct Foo {
bar = string, // explicit: bar: string | null
baz = string #required, // required: baz: string
}
This enforcer only affects TypeScript. Rust output is always Option<T> with #[serde(skip_serializing_if = "Option::is_none")].
#nullableUse #nullable to allow a field’s value to be null.
Combined with #required, the key is always present but the value may be null:
struct User {
name = string #required,
nickname = string #nullable #required, // always present, value can be null
bio = string, // optional field
}
TypeScript:
export interface User {
name: string;
nickname: string | null;
bio?: string | null;
}
Rust:
pub struct User {
pub name: String,
pub nickname: Option<String>, // no skip_serializing_if — always serialized
#[serde(skip_serializing_if = "Option::is_none")]
pub bio: Option<String>,
}
Import specific types from another file:
@import ./models.wst { User, Role };
By default, imports are linked — generated code references the type as coming from its source module.
Use ^copy to inline the imported types directly into this file’s output:
@import ./models.wst ^copy { User, Role };
Import an entire file under a namespace alias:
@import ./models.wst *Models;
struct Session {
user = Models.User #required,
}
Use copy inside a struct to inline all fields from another struct. Copy directives must appear before any field definitions.
struct Base {
id = int64 #required,
name = string #required,
created_at = string,
}
struct Extended {
copy Base,
email = string #required,
}
Extended compiles as if it had all of Base’s fields followed by its own. Multiple copies are allowed. A field name may not appear more than once across all copies and direct fields.
@-ops are compile-time type transformations. They can be used anywhere a struct type reference is accepted (e.g. in copy directives).
@exclude@exclude(StructType, ["field1", "field2"])
Returns the struct with the listed fields removed.
struct Record {
id = int64 #required,
name = string #required,
internal = string,
}
struct PublicRecord {
copy @exclude(Record, ["internal"]),
}
TypeScript:
export interface PublicRecord {
id: number;
name: string;
}
@exclude can be combined with additional fields:
struct Summary {
copy @exclude(Record, ["internal"]),
summary = string #required,
}
Struct field names can be qualified with an enum type using dot notation. This is useful when field names are driven by an enum’s cases.
enum Env { production, staging }
struct DeploymentConfig {
Env.production = InstanceConfig,
Env.staging = InstanceConfig,
}
The qualifier is stripped in generated output — the emitted field name is the part after the dot. In Rust, #[serde(rename = "Env.production")] is added so the wire format preserves the qualified name.
This pairs naturally with assertions:
struct DeploymentConfig {
Env.production = InstanceConfig,
Env.staging = InstanceConfig,
assert ($s) {
for $k in Env { $s haskey $k }
},
}
Assertions are compile-time checks that validate structural invariants. They run during validation and produce no output in generated code.
Attach an assert block directly to a struct. $s is a metavariable bound to the struct under test.
enum Env { production, staging }
struct DeploymentConfig {
Env.production = InstanceConfig,
Env.staging = InstanceConfig,
assert ($s) {
for $k in Env {
$s haskey $k
}
},
}
This asserts that DeploymentConfig has a field for every case in Env.
Define a reusable assertion with assertion, then reference it by name:
assertion CoversEnv (struct $s) {
for $k in Env {
$s haskey $k
}
}
struct DeploymentConfig {
Env.production = InstanceConfig,
Env.staging = InstanceConfig,
assert CoversEnv,
}
Assertion failures are reported at the assert call site, not inside the assertion definition body.
| Construct | Description |
|---|---|
assert ($s) { ... } |
Inline assertion; $s bound to the struct |
assert Name |
Reference a named assertion |
assertion Name (struct $s) { ... } |
Top-level named assertion definition |
for $k in EnumName { ... } |
Iterates over every case of EnumName, binding each name to $k |
$s haskey $k |
Asserts that struct $s has a field whose name matches the current value of $k |
Metavariables are always prefixed with $. Field name matching in haskey is exact (case-sensitive).
Constants define named values. Names must be SCREAMING_CASE.
const DEFAULT_ERROR = ApiError.NotFound
const DEFAULT_CONFIG = ServiceConfig {
timeout_ms = 5000,
retries = 3,
label = "default",
}
| Value | Example |
|---|---|
| String | "hello" |
| Number | 42, 3.14 |
| Boolean | true, false |
| Null | null |
| Enum case | Status.Active |
| Struct literal | Type { field = value, ... } |
Struct literals support nesting and dotted field keys. Unspecified optional fields are automatically filled with null / None in output.
export const DEFAULT_ERROR: ApiError = ApiError.NotFound;
export const DEFAULT_CONFIG: ServiceConfig = {
timeout_ms: 5000,
retries: 3,
label: "default",
};
Enum cases and non-string structs compile to pub const or pub static:
pub const DEFAULT_ERROR: ApiError = ApiError::NotFound;
Structs containing string fields use std::sync::LazyLock (stable since Rust 1.80), because String is heap-allocated and cannot be constructed in a const/static initializer. LazyLock initialises once on first access:
pub static DEFAULT_CONFIG: std::sync::LazyLock<ServiceConfig> = std::sync::LazyLock::new(|| ServiceConfig {
timeout_ms: 5000,
retries: 3,
label: "default".to_string(),
other_optional_field: None,
});
String values are emitted as "...".to_string(). Optional fields not present in the constant are explicitly set to None — no Default trait required.