Skip to content

Promote literal-discriminated unions to a first-class kind#22

Merged
guybedford merged 1 commit intomainfrom
discriminated-unions
May 5, 2026
Merged

Promote literal-discriminated unions to a first-class kind#22
guybedford merged 1 commit intomainfrom
discriminated-unions

Conversation

@guybedford
Copy link
Copy Markdown
Contributor

A type alias type Foo = A | B | … whose branches share a required, literal-typed property now lands in a new TypeKind::DiscriminatedUnion rather than merging into a flat InterfaceDecl. The discriminator can be a string, number, or boolean literal — the three forms TS narrows on.

The motivation is per-branch required-vs-optional accuracy. With the previous merged-shape model, EmailAttachment.contentId (required in the inline branch, ?: undefined in attachment) collapsed to merged-optional, so new_inline(...) didn't take it as a parameter even though the source contract requires it:

// before
EmailAttachment::new_inline(filename: &str, type_: &str, content: &str)
// after
EmailAttachment::new_inline(content_id: &str, filename: &str, type_: &str, content: &str)
EmailAttachment::new_attachment(filename: &str, type_: &str, content: &str)

The codegen keeps a per-branch view:

  • Extern block stays merged — one pub type Foo plus a getter/setter set per property across all branches. No change for downstream consumers reaching for the bare type.
  • impl Foo emits one new_<discriminator> / builder_<discriminator> cohort per branch, driven by that branch's required set. Each branch independently goes through the literal-collapse / value-union expansion / _with_<type> suffixing rules.
  • Wrapper builder is still merged — EmailAttachmentBuilder::content_id(self, val) is always available so callers going through builder_attachment(...).content_id(x).build() aren't blocked. The branch invariant is enforced at the new_<discriminator> boundary; once past that, the wrapper exposes the runtime-permissive view of the type.
  • builder_<branch> suppression: when a branch's required set already covers every merged-optional field, the builder_<branch> companion would have nothing to chain — so new_<branch> is emitted with an inlined body and no builder. For EmailAttachment, inline covers the only merged-optional (contentId) so it gets an inlined body, while attachment leaves contentId for the wrapper and keeps its builder_attachment companion.

Implementation:

  • parse/literal_union.rs::detect_discriminators — a property qualifies when it's required and string/number/boolean-literal-typed in every branch.
  • parse/first_pass/populate.rs::try_synthesize_alias_decl — returns TypeKind (was InterfaceDecl); routes to DiscriminatedUnion when discriminators are detected, plain Interface otherwise.
  • codegen/classes.rsgenerate_dictionary_factory is now a thin wrapper around generate_dictionary_factory_with_passes, which takes an optional list of required-field passes. Plain interfaces pass nothing and behave as before; the new module passes one entry per branch. A shared emitted_names set deduplicates combos that converge across passes, and the per-pass pass_covers_optionals check drives the new builder_<branch> suppression.
  • codegen/discriminated_unions.rs — new module wiring DiscriminatedUnionDecl through the shared infra.

Snapshot diffs are limited to discriminated unions and dictionaries that previously landed alphabetical (now source-order, per #21). Plain interfaces are unchanged. CONVENTIONS.md gains a ## Discriminated unions section spelling out the detection rule, the per-branch factory shape, the wrapper-stays-merged invariant, and the builder_<branch> suppression rule.

A type alias `type Foo = A | B | …` whose branches share a required,
literal-typed property now lands in a new `TypeKind::DiscriminatedUnion`
rather than merging into a flat `InterfaceDecl`. The discriminator can
be a string, number, or boolean literal — the three forms TS narrows on.

The motivation: the merged-shape model erased per-branch
required-vs-optional information. `EmailAttachment.contentId` is
required in the `inline` branch and absent (`?: undefined`) in the
`attachment` branch; the merge marked it optional everywhere, so
`new_inline(...)` didn't take it as a parameter even though the source
contract requires it.

The codegen now keeps a per-branch view: the extern block stays merged
(one getter/setter set per property across branches), the `impl` emits
one `new_<discriminator>` / `builder_<discriminator>` cohort per branch
driven by that branch's required set, and the wrapper builder still
exposes the merged-optional fluent setters (callers going through
`builder_attachment(...).content_id(x).build()` aren't blocked — the
branch invariant is enforced at the `new_<discriminator>` boundary).

`generate_dictionary_factory` was refactored to take an optional list
of required-field passes; plain interfaces / dictionaries pass nothing
and behave as before, discriminated unions thread one pass per branch.
The shared `emitted_names` set deduplicates combos that converge across
passes, and a per-pass `pass_covers_optionals` check skips the
`builder_<branch>` companion when a branch already covers every
merged-optional field — so `new_inline(content_id, …)` (which covers
the only optional) gets an inlined body, while `new_attachment(…)`
(which leaves `contentId` to the wrapper) keeps its `builder_attachment`
companion.

Snapshot updates are limited to the dictionaries that previously
landed alphabetical (now source-order, see #21) and the discriminated
unions: `EmailAttachment`, `KVNamespaceListResult`,
`ReadableStreamReadResult`, `Status`, `D1Response`, …. Plain
interfaces are unchanged.
@guybedford guybedford merged commit da33c4a into main May 5, 2026
4 checks passed
@guybedford guybedford deleted the discriminated-unions branch May 5, 2026 00:48
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.

1 participant