Skip to content

Support runtime union types via dynamic-union enums#4734

Merged
guybedford merged 1 commit into
mainfrom
enum-fallbacks
May 6, 2026
Merged

Support runtime union types via dynamic-union enums#4734
guybedford merged 1 commit into
mainfrom
enum-fallbacks

Conversation

@guybedford

@guybedford guybedford commented Oct 10, 2025

Copy link
Copy Markdown
Contributor

This adds support for dynamic unions: a #[wasm_bindgen] enum that mixes
string-literal variants with single-field tuple variants is exported as an
untagged TypeScript union and dispatched dynamically at the JS↔Rust boundary.

The motivation is to express common JavaScript shapes — "loading" | string | SomeClass | OtherClass — as a single Rust enum that can be match-ed
exhaustively. Discrimination is dynamic: each variant is tested in
declaration order via as_string for string literals and TryFromJsValue
for typed payloads.

#[wasm_bindgen]
pub struct Foo { /* ... */ }

#[wasm_bindgen]
pub enum ApiResult {
    Success = "success",
    Failure = "failure",
    Data(String),
    Foo(Foo),
}

produces the TypeScript

export type ApiResult = "success" | "failure" | string | Foo;

Resolves #2088, resolves #2153.

What's implemented

  • New schema entry DYNAMIC_UNION and per-variant descriptor exports
    (__wbg_dynamic_union_<name>_<idx>) so cli-support can resolve variant
    TypeScript names through the normal descriptor pipeline.
  • ast::DynamicUnion parser path triggered by any tuple-style variant on a
    #[wasm_bindgen] enum. The previous error "enum variants with associated
    data are not supported" is removed; mixed unit + tuple variants are now
    parsed as a dynamic union.
  • Generated IntoWasmAbi / FromWasmAbi / TryFromJsValue /
    OptionIntoWasmAbi / OptionFromWasmAbi / From<T> for JsValue impls,
    plus WasmDescribe carrying the variant type list.
  • String-literal variants are coalesced into a single as_string check
    with an inner match, so worst-case dispatch is one string read
    regardless of how many literal variants exist. Literal variants always
    win against a generic String arm regardless of declaration order.
  • cli-support plumbing: Descriptor::DynamicUnion,
    AdapterType::DynamicUnion, incoming/outgoing externref lowering, and
    option-position handling so Option<DynamicUnion> works.
  • TypeScript rendering: AuxDynamicUnionVariant::{Literal, Type} defers
    variant TS rendering to JS-generation time, where renamed types resolve
    through qualified_to_identifier. A reference closure
    (expand_typescript_refs) walks transitively-referenced types so nested
    unions and string enums used as variants emit their full type aliases.
  • String enums and dynamic unions both emit export type (was bare
    type) so the alias is a named module export.
  • private flag honoured by string enums and dynamic unions to suppress
    the export keyword.

#[wasm_bindgen(fallback)]

The new enum-level fallback attribute makes the last tuple variant an
unconditional catch-all. This is the supported pattern for unions whose
trailing variant has no meaningful runtime check (e.g., interface-only
imports — types declared via extern \"C\" { type Foo; } with
typescript_type but no JS-side runtime construct, whose instanceof
check always returns false).

#[wasm_bindgen]
extern \"C\" {
    #[wasm_bindgen(typescript_type = \"Shape\")]
    pub type Shape;
}

#[wasm_bindgen(fallback)]
pub enum ApiResult {
    Loading = \"loading\",
    Empty = \"empty\",
    Anything(Shape),
}

renders as \"loading\" | \"empty\" | Shape. Strings \"loading\" and
\"empty\" route to their named variants; anything else is unconditionally
accepted as Anything(_).

Nesting and Option

A variant payload may itself be another dynamic union or a string enum,
and the union may appear inside Option:

#[wasm_bindgen]
pub enum Inner { Foo = \"foo\", Bar = \"bar\", Number(u32) }

#[wasm_bindgen]
pub enum Outer { Loading = \"loading\", Wrapped(Inner), Bare(Foo) }

generates:

export type Inner = \"foo\" | \"bar\" | number;
export type Outer = \"loading\" | Inner | Foo;

Option<DynamicUnion> works in both argument and return positions and
renders as the standard T | null | undefined / T | undefined pair.

C-style enum TryFromJsValue fix

This PR fixes a separate bug uncovered by the new tests: c-style enum
TryFromJsValue was applying JS unary + coercion via f64::try_from,
silently coercing strings, booleans, null, and arrays into numbers. As a
result dyn_into::<MyEnum>() on a string would produce a valid enum
value (typically the variant with discriminant 0, since NaN as u32 = 0),
violating the TryFromJsValue contract.

The conversion now uses value.as_f64(), which only succeeds on actual
JS numbers. This affects two surfaces:

  • dyn_into::<MyEnum>() for c-style enums correctly returns Err for
    non-numeric input.
  • Vec<MyEnum> from JS rejects elements that aren't numbers (the
    existing tests/wasm/enum_vecs.rs::test_invalid expected this but was
    previously a no-op because the bypass coerced through).

Round-trip benchmark

benches/enum_roundtrip.rs measures string-enum vs dynamic-union round-trip
cost through a JS shim. On Node 25 in release mode:

  • string_enum_roundtrip: 118.77 ns
  • dynamic_union_roundtrip: 119.77 ns

Within ~1%. The runtime cost difference between the two encodings is
effectively noise — a developer-facing comment on ToTokens for ast::StringEnum
notes this and outlines the path to subsume string enums into dynamic
unions in a future PR.

Test coverage

  • tests/wasm/enums.rs — round-trips now go through JS shims declared in
    tests/wasm/enums.js to actually exercise the dispatcher (previously
    these called exported Rust functions Rust-to-Rust, bypassing
    into_abi / from_abi entirely). Tests cover string-literal variants,
    primitive payloads, exported and imported types, nesting, Option, and
    the new fallback attribute.
  • crates/cli/tests/reference/dynamic-union.{rs,d.ts,bg.js,js,wat}
    full reference fixture covering literals, fallback, exported struct,
    imported type (annotated with typescript_type), nesting,
    Option<Wrapper>, and a private variant.
  • crates/typescript-tests/src/dynamic_union.{rs,ts} — TS-side jest
    tests asserting the generated .d.ts exposes union types as named
    aliases, that variants typecheck both as literals and as object payloads,
    that nested literals dispatch through the outer→inner chain, and that
    Option<Wrapped> typechecks as T | null | undefined.
  • crates/macro/ui-tests/invalid-enums.{rs,stderr} — new error coverage
    for variants with named fields, variants with multiple unnamed fields,
    and unit variants without a string discriminant in a tuple-bearing enum.
  • tests/wasm/enum_vecs.js — updated to assert the correct error
    message ("array contains a value of the wrong type") that the strict
    TryFromJsValue now produces.

Naming

The feature is named dynamic union throughout — "dynamic" describes the
discrimination mechanism (runtime inspection per variant), distinguishing it
from compile-time TS unions and from Rust's own tagged enums. The original
draft used "discriminated union" which is technically the opposite of
what this is (these are untagged at the JS boundary).

Schema

Schema hash bumped to 9718506702724923631.

@guybedford guybedford force-pushed the enum-fallbacks branch 2 times, most recently from 3f4ca60 to 383f625 Compare October 14, 2025 16:33
@RReverser RReverser requested review from a team and daxpedda October 15, 2025 19:44
@RReverser

Copy link
Copy Markdown
Member

Just to reflect some private concerns here: this seems like an extension of new syntax sugar that I know @daxpedda wanted to try and avoid in the core, so I'm going to defer to him for review.

E.g. once we land this, what if users might want all other Serde enum representations built into wasm-bindgen as well? It seems we might want to answer a question of where we draw a line between built-in serialization and things covered by serde-wasm-bindgen integration before adding features from said subset.

There are definitely valid reasons to add some integrations to the core, at least for optimisations alone as serde-wasm-bindgen doesn't have as much access to type information at compile-time as wasm-bindgen itself does, but we need to make sure we don't paint ourselves in the corner and on the hook for maintenance of even larger API area with all the semver difficulties that wasm-bindgen already comes with.

Maybe there are hooks we could expose instead to make tools like serde-wasm-bindgen more efficient or support more types, so that the core remains lean? I don't know the answer, but something worth exploring.


Offtop: what you're adding here is rather the untagged enums not tagged ones.

@guybedford guybedford changed the title Support runtime union types with tagged enums Support runtime union types with untagged enums Oct 21, 2025

@daxpedda daxpedda left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See #2088 and #2407 for previous discussions.

I definitely have the concerns pointed out by RReverser, these are also laid out more in #2088.

However, I don't think its realistic that we are getting any sort of better output customization in wasm-bindgen in this version, because its just going be too difficult to design. Maybe we can live with an ad-hoc implementation just for this version, but I'm apprehensive about it.

@guybedford did you ever take a look at tsify? Again, I don't think wasm-bindgen is offering something here, in the end its all a JsValue and we aren't providing an optimized conversion. The TS part can be handled with typescript_type.

I think it would be good to discuss this in the upcoming meeting and how we want to proceed in general.

@guybedford guybedford force-pushed the enum-fallbacks branch 3 times, most recently from f0adc28 to 771867a Compare November 22, 2025 01:23
@guybedford guybedford force-pushed the enum-fallbacks branch 2 times, most recently from 17c5a0a to b114197 Compare December 1, 2025 06:15
@codspeed-hq

codspeed-hq Bot commented Dec 1, 2025

Copy link
Copy Markdown

CodSpeed Performance Report

Merging #4734 will not alter performance

Comparing enum-fallbacks (0f9ca45) with main (96f3e1e)

Summary

✅ 4 untouched

@guybedford guybedford force-pushed the enum-fallbacks branch 3 times, most recently from f0a1e6b to 2995e17 Compare May 4, 2026 22:50
@guybedford guybedford changed the title Support runtime union types with untagged enums Support runtime union types via dynamic-union enums May 4, 2026
@guybedford

Copy link
Copy Markdown
Contributor Author

@RReverser @daxpedda I've updated this PR to support TypeScript generation and deeper integration into the type system, as well as renaming to DynamicUnion instead of discriminated per Ingvar's original comment here. As one of the last gaps in JS type system inference, it would be good this one further finally.

@daxpedda daxpedda dismissed their stale review May 5, 2026 17:59

Don't want to block!

@daxpedda daxpedda left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe my review from last time still stands. As long as you are doing dynamic conversion, ergo find the right type in Wasm, this whole implementation can be done externally.

The part that can't be done externally is exporting, but I believe this isn't part of what you need?

(however, my conclusion below is that we should instead merge it, so I'm not advocating for doing this externally, just pointing it out)


So about the design space:

  • Regardless of what kind of enum we have/introduce, the user should be able to control what to import and what to export. In practice this means being in control if JS/TS is generated for an enum.
  • Right now we only support string and integer enums. In Rust they are represented as data-free enums (only unit variants).
  • The second type of enum we want to support is one where variants are actual JS object with fields. In Rust these would be represented as enum with struct or tuple variants.
    • Your implementation does something a bit different: it uses imported or exported types in tuple variants instead of inlining the properties. But these two are not in conflict, so we can proceed with this design.

In conclusion: I think your design works just fine and I do not believe that it will create conflicts in the future. I will defer to you on how the JS/TS output looks like. My concerns about import vs export still stand but the defaults are already messed up and the added design doesn't make it better or worse, but is instead consistent with whats already in place.


Just approving design, not code!

@daxpedda

daxpedda commented May 5, 2026

Copy link
Copy Markdown
Member

Also for context on where we stood on this in the past: I was generally hoping that we could avoid having everything in wasm-bindgen proper. But I gave up on that dream because we don't intend to do breaking changes anymore and instead are focusing on fixing this in js-bindgen.

@guybedford guybedford force-pushed the enum-fallbacks branch 6 times, most recently from a600f54 to 1e3e38a Compare May 6, 2026 20:25
@guybedford guybedford enabled auto-merge (squash) May 6, 2026 20:27
@guybedford guybedford merged commit f4706e3 into main May 6, 2026
64 of 65 checks passed
@guybedford guybedford deleted the enum-fallbacks branch May 6, 2026 21:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Typescript definition not generated for string enums Support enums for more accurate static types

3 participants