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
- [ ] A Rust crate built with wasm-bindgen, or a C module with an enum in its API.
- [ ] For data-carrying enums,
serdeandserde-wasm-bindgen, as in serializing data with serde-wasm-bindgen.
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.
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.
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
Circleversus JavaScript"circle". Setrename_allexplicitly.
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.
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.
Related
- Writing your first WIT interface — enums and variants in WIT.
- Returning structs from Wasm to JavaScript — the struct counterpart.
- Customising TypeScript output from wasm-bindgen — typing tagged unions.
- Mapping C error codes to JavaScript errors — numeric codes from C.