Rust Enums in Wasm
Enums: Rust's Superpower
Rust enums are far more powerful than enums in most languages. They can hold data, have methods, and enforce exhaustive pattern matching at compile time. But crossing the Wasm boundary introduces challenges — JavaScript has no concept of Rust's rich enum types.
C-Style Enums: The Easy Case
Simple enums without data map directly to JavaScript numbers via wasm_bindgen:
#[wasm_bindgen]
pub enum Direction {
Up = 0,
Down = 1,
Left = 2,
Right = 3,
}This generates a TypeScript definition:
export enum Direction {
Up = 0,
Down = 1,
Left = 2,
Right = 3,
}Key rule: #[wasm_bindgen] only supports C-style enums (no data in variants).
Using repr for Memory Layout
#[repr(u8)] // 1 byte
enum Small { A, B, C }
#[repr(u32)] // 4 bytes, matches JS number
enum JsCompatible { X, Y, Z }Memory layout with #[repr(u8)]:
┌────┐
│ 00 │ = A (1 byte)
├────┤
│ 01 │ = B (1 byte)
├────┤
│ 02 │ = C (1 byte)
└────┘
Without repr, Rust chooses the smallest representation.Complex Enums: The Hard Case
When your enum variants contain data, wasm_bindgen can't export them directly:
// This WON'T compile with #[wasm_bindgen]
enum Shape {
Circle { radius: f64 }, // has data
Rectangle { w: f64, h: f64 }, // has data
Point, // no data
}Error: wasm_bindgen doesn't support enums with data fields. You need a workaround.
Workaround 1: serde-wasm-bindgen
The most popular solution is to serialize complex enums to JsValue using serde:
use serde::{Serialize, Deserialize};
use wasm_bindgen::prelude::*;
#[derive(Serialize, Deserialize)]
#[serde(tag = "type")] // internally tagged
pub enum Shape {
Circle { radius: f64 },
Rectangle { width: f64, height: f64 },
Point,
}
#[wasm_bindgen]
pub fn create_shape(kind: &str, params: JsValue) -> JsValue {
let shape: Shape = match kind {
"circle" => Shape::Circle {
radius: serde_wasm_bindgen::from_value(params).unwrap()
},
_ => Shape::Point,
};
serde_wasm_bindgen::to_value(&shape).unwrap()
}Serde Tagging Strategies
Serde offers several ways to represent enums in JSON:
| Strategy | Attribute | JSON Output |
|---|---|---|
| Externally tagged (default) | none | {"Circle": {"radius": 5}} |
| Internally tagged | #[serde(tag = "type")] |
{"type": "Circle", "radius": 5} |
| Adjacently tagged | #[serde(tag = "t", content = "c")] |
{"t": "Circle", "c": {"radius": 5}} |
| Untagged | #[serde(untagged)] |
{"radius": 5} |
Externally tagged (default):
{ "Circle": { "radius": 5.0 } }
~~~~~~~~ ~~~~~~~~~~~~~~~~
variant data
Internally tagged (#[serde(tag = "type")]):
{ "type": "Circle", "radius": 5.0 }
~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~
discriminant data (flattened)
Adjacently tagged (#[serde(tag = "t", content = "c")]):
{ "t": "Circle", "c": { "radius": 5.0 } }
~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~
discriminant data (nested)Workaround 2: Manual Conversion with Structs
For performance-sensitive code, avoid serde overhead by manually converting:
#[wasm_bindgen]
pub struct ShapeData {
kind: u8, // 0=Circle, 1=Rect, 2=Point
param1: f64, // radius or width
param2: f64, // 0 or height
}
#[wasm_bindgen]
impl ShapeData {
pub fn circle(radius: f64) -> ShapeData {
ShapeData { kind: 0, param1: radius, param2: 0.0 }
}
pub fn rectangle(width: f64, height: f64) -> ShapeData {
ShapeData { kind: 1, param1: width, param2: height }
}
pub fn area(&self) -> f64 {
match self.kind {
0 => std::f64::consts::PI * self.param1 * self.param1,
1 => self.param1 * self.param2,
_ => 0.0,
}
}
}Workaround 3: TypeScript Union Types via tsify
The tsify crate generates proper TypeScript discriminated unions:
use tsify::Tsify;
use serde::{Serialize, Deserialize};
#[derive(Tsify, Serialize, Deserialize)]
#[tsify(into_wasm_abi, from_wasm_abi)]
#[serde(tag = "type")]
pub enum GameEvent {
Move { x: f64, y: f64 },
Attack { target_id: u32, damage: f64 },
Heal { amount: f64 },
Disconnect,
}This generates:
export type GameEvent =
| { type: "Move"; x: number; y: number }
| { type: "Attack"; target_id: number; damage: number }
| { type: "Heal"; amount: number }
| { type: "Disconnect" };Now TypeScript's type narrowing works perfectly:
function handleEvent(event: GameEvent) {
switch (event.type) {
case "Move":
console.log(`Moving to (${event.x}, ${event.y})`);
break;
case "Attack":
console.log(`Attacking ${event.target_id} for ${event.damage}`);
break;
}
}Performance Comparison
| Method | Serialization Cost | Type Safety | JS Ergonomics |
|---|---|---|---|
| C-style enum | None (u32) | Excellent | Good |
| serde-wasm-bindgen | Medium (JSON-like) | Good | Excellent |
| Manual struct | None | Manual | Fair |
| tsify | Medium | Excellent | Excellent |
Option and Result Across the Boundary
Option<T> maps to T | undefined in JavaScript for primitive types:
#[wasm_bindgen]
pub fn find_user(id: u32) -> Option<String> {
if id == 1 { Some("Alice".into()) } else { None }
}const name: string | undefined = find_user(1); // "Alice"
const none: string | undefined = find_user(99); // undefinedResult<T, E> throws a JavaScript exception on Err:
#[wasm_bindgen]
pub fn parse_number(s: &str) -> Result<f64, JsError> {
s.parse::<f64>().map_err(|e| JsError::new(&e.to_string()))
}try {
const n = parse_number("42.5"); // 42.5
const bad = parse_number("abc"); // throws!
} catch (e) {
console.error(e.message); // "invalid float literal"
}Key Takeaways
- C-style enums work directly with
#[wasm_bindgen]— they map to JS numbers - Complex enums (with data) need a workaround:
serde-wasm-bindgen, manual structs, ortsify - serde's tagging strategies control how enum variants appear in JSON — internally tagged is usually best for JS
- tsify generates TypeScript discriminated unions, giving you full type safety on both sides
Option<T>maps toT | undefined,Result<T, E>maps to return-or-throw- For hot paths, avoid serde overhead — use C-style enums or manual conversion structs