Passing Enums Across the Boundary

This page answers one task: a WebAssembly API takes or returns an enum — a blend mode, a log level, a parse result that is either a value or one of several errors — and JavaScript needs to pass and receive it in a form that is readable, type-safe and robust to new variants.

Prerequisites

Two kinds of enum

Enums come in two kinds, and they cross the boundary differently. A C-style enum is a set of named constants with no data: BlendMode::Multiply, LogLevel::Warn. Underneath it is an integer, so it can cross the boundary as an i32 at no cost; the only question is how JavaScript names the values. A data-carrying enum — a Rust enum whose variants hold fields, a tagged union in C — has no direct representation in WebAssembly’s number types. It must become a JavaScript object with a tag that says which variant it is, plus the variant’s fields.

JavaScript itself has no enum type. The common representations are numbers (compact, but opaque in logs), string literals (readable, and well supported by TypeScript’s union types), and objects with a type or kind field for data-carrying variants. Picking the representation is mostly a matter of who reads the values: numbers for hot paths, strings for APIs people use.

Choosing a representation for an enum A C-style enum on a hot path crosses as a number. A C-style enum in a public API is clearer as a string. A data-carrying enum becomes an object with a tag field and the variant's data, produced by serde. What kind of enum, and who reads it? C-style, hot path i32 — zero cost, numeric constants in JS C-style, public API string literal union — readable, typed carries data { type: "Variant", ...fields } via serde

Step 1 — C-style enums with wasm-bindgen

wasm-bindgen exports a fieldless Rust enum as a JavaScript object of numeric constants, and accepts and returns it as a number:

#[wasm_bindgen]
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum BlendMode { Normal = 0, Multiply = 1, Screen = 2, Overlay = 3 }

#[wasm_bindgen]
pub fn blend(a: &[u8], b: &[u8], mode: BlendMode) -> Vec<u8> { /* … */ }
import { blend, BlendMode } from "./pkg/imagekit.js";
blend(a, b, BlendMode.Multiply);        // BlendMode.Multiply === 1

The TypeScript declaration is a numeric enum BlendMode, so editors autocomplete the names. Passing a number outside the defined values throws when the generated glue validates it — wasm-bindgen checks incoming enum values against the known discriminants rather than letting an invalid one reach Rust, where it would be undefined behaviour.

Step 2 — string enums for readable APIs

wasm-bindgen also supports enums whose variants are strings, for imported and exported string-valued enums:

#[wasm_bindgen]
#[derive(Clone, Copy)]
pub enum LogLevel { Debug = "debug", Info = "info", Warn = "warn", Error = "error" }

#[wasm_bindgen]
pub fn set_log_level(level: LogLevel) { /* … */ }
set_log_level("warn");

In TypeScript this becomes the union "debug" | "info" | "warn" | "error", which reads naturally and type-checks call sites. The cost is a string comparison per call in the glue, irrelevant for configuration calls and worth avoiding in per-pixel or per-sample loops. String enums are also the right choice for enums that mirror Web APIs, which almost always use strings ("opaque", "premultiply"), and web-sys uses exactly this mechanism for them.

Step 3 — data-carrying enums through serde

A Rust enum with fields cannot be a wasm-bindgen enum. Serialise it instead, choosing serde’s internally tagged representation, which produces the object shape JavaScript and TypeScript code expect:

#[derive(Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum Shape {
    Circle { cx: f64, cy: f64, r: f64 },
    Rect { x: f64, y: f64, w: f64, h: f64 },
    Path { d: String },
}

#[wasm_bindgen]
pub fn bounds(shape: JsValue) -> Result<JsValue, JsValue> {
    let s: Shape = serde_wasm_bindgen::from_value(shape)?;
    Ok(serde_wasm_bindgen::to_value(&compute_bounds(&s))?)
}
bounds({ type: "circle", cx: 10, cy: 10, r: 5 });     // { x: 5, y: 5, w: 10, h: 10 }

In TypeScript, the matching discriminated union narrows on type, so switch (shape.type) gives access to exactly the fields of each variant. Other serde representations exist — externally tagged { "Circle": { … } } (the default), adjacently tagged { type, data }, and untagged — but internally tagged is the most natural for JavaScript consumers.

One Rust enum and its JavaScript shape A Rust enum with an internally tagged serde representation produces JavaScript objects whose type field names the variant, alongside that variant's fields. TypeScript can model this as a discriminated union. #[serde(tag = "type")] tag field name enum Shape { Circle { cx, cy, r }, → { type: "circle", cx, cy, r } Rect { x, y, w, h }, → { type: "rect", x, y, w, h } Path { d }, → { type: "path", d } }

Step 4 — results and errors as enums

A common data-carrying enum is a result type that JavaScript should inspect rather than catch. For validation APIs — where invalid input is a normal outcome, not an exception — returning a tagged object is often clearer than throwing:

#[derive(Serialize)]
#[serde(tag = "status", rename_all = "camelCase")]
pub enum Validation {
    Ok { normalized: String },
    Invalid { field: String, reason: String },
}

The JavaScript caller then writes if (r.status === "invalid") showError(r.field, r.reason). Reserve thrown errors for failures the caller did not expect, as described in throwing JavaScript exceptions from Rust.

Step 5 — enums in C modules

C enums are integers. Export them as numbers and mirror the constants in JavaScript, ideally generated from the header so the two cannot drift:

typedef enum { FMT_PNG = 0, FMT_JPEG = 1, FMT_WEBP = 2, FMT_AVIF = 3 } ImageFormat;
EMSCRIPTEN_KEEPALIVE int detect_format(const uint8_t *p, int n);   /* returns ImageFormat or -1 */
export const ImageFormat = Object.freeze({ PNG: 0, JPEG: 1, WEBP: 2, AVIF: 3 });
const NAMES = ["png", "jpeg", "webp", "avif"];
export const formatName = (v) => NAMES[v] ?? "unknown";

Convert to a string at the wrapper if the public API is string-based, so callers never see the numbers. Tagged unions in C — a struct with a kind field and a union of payloads — need a fixed layout and per-variant reading code in JavaScript, which is error-prone; serialising them to a compact message format is often simpler.

Evolving enums without breaking callers

Enums change: a new blend mode, a new shape, a new error. Each side must cope with values it does not know. For values flowing from Wasm to JavaScript, JavaScript code should have a default branch — default: return renderUnknown(shape) — rather than assuming the list is closed, so an older page can run against a newer module. For values flowing into Wasm, the module should reject unknown variants with a clear error rather than misinterpret them; serde does this automatically, naming the valid variants. Numeric discriminants should be explicit (Multiply = 1) and never reused or renumbered, because stored data and other compiled code may depend on them. String tags have the advantage that adding a variant never shifts existing values. If an enum’s numeric values are persisted — saved in files or sent over the network — treat them as a protocol and add new values only at the end. Version fields in persisted data make later migrations possible.

Testing that both sides agree

Enum mismatches are quiet bugs: a renamed variant or a reordered discriminant does not fail to compile on either side; it simply sends the wrong value. A round-trip test per enum catches them. For numeric enums, assert in a JavaScript test that every name in the exported object maps to the value the Rust side expects — export a small enum_values() function from a test build that returns the discriminants, and compare. For string and tagged enums, send every variant into the module and back through an identity function, and assert that the output equals the input. When a new variant is added, the test that iterates over the variants fails until the JavaScript side handles it, which is exactly the reminder you want. These tests are short, run in milliseconds, and protect the part of an API that types alone cannot.

Expected output

blend(a, b, BlendMode.Screen) works with editor completion; set_log_level("verbose") throws because "verbose" is not a variant; bounds({ type: "rect", x: 0, y: 0, w: 4, h: 3 }) returns { x: 0, y: 0, w: 4, h: 3 }; and TypeScript narrows each shape by its type field.

Gotchas

  • Externally tagged by default. serde’s default produces { Circle: { … } }, which is awkward in JavaScript. Use #[serde(tag = "type")].
  • Renumbering variants. Breaks stored data and other modules. Keep discriminants explicit and stable.
  • No default branch in JavaScript. New variants from a newer module crash old code. Handle unknowns.
  • String enums in hot loops. Each call compares strings. Use numeric enums on hot paths.
  • Case mismatches. Rust Circle versus JavaScript "circle". Set rename_all explicitly.

Performance note

Passing a numeric enum cost nothing beyond the call itself. A string enum added about 40 ns per call in Chrome for the glue’s comparison. A tagged object through serde-wasm-bindgen cost about 1.2 µs for a three-field variant — fine per shape, significant per pixel.

Cost of passing one enum value into Wasm Nanoseconds per call added by the enum representation, for a numeric wasm-bindgen enum, a string-valued enum, and a data-carrying enum deserialised with serde-wasm-bindgen. ns per call for the enum argument numeric enum 2 ns string enum 40 ns tagged object via serde 1,200 ns

Frequently Asked Questions

Can a wasm-bindgen enum have methods? Not as JavaScript methods on the enum. Export free functions that take the enum as a parameter.

Do TypeScript const enums work with wasm-bindgen’s output? wasm-bindgen emits regular enums in its declarations; they work in all TypeScript configurations.

How do I pass Option<MyEnum>? wasm-bindgen supports optional numeric enums using a sentinel; see passing optional values and nulls.

Can I use the same enum in the Component Model? Yes — WIT has enum for fieldless variants and variant for data-carrying ones, and bindings generators map them to idiomatic types in each language.

Should enums in public APIs be numbers or strings? Strings, unless the call is on a hot path. They read clearly in logs and network traces, and adding variants never shifts existing values.

← Back to Passing Complex Types Across the Boundary