← Back to Lessons Lesson 31 of 48
Intermediate data-structures

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); // undefined

Result<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

  1. C-style enums work directly with #[wasm_bindgen] — they map to JS numbers
  2. Complex enums (with data) need a workaround: serde-wasm-bindgen, manual structs, or tsify
  3. serde's tagging strategies control how enum variants appear in JSON — internally tagged is usually best for JS
  4. tsify generates TypeScript discriminated unions, giving you full type safety on both sides
  5. Option<T> maps to T | undefined, Result<T, E> maps to return-or-throw
  6. For hot paths, avoid serde overhead — use C-style enums or manual conversion structs

Try It