Skip to content

Docs: unboxed variant "no overlap" rule is stated incorrectly — constant vs payload overlap is allowed and undocumented #1337

Open

Description

@cristianoc

Problem

The manual states that unboxed variants cannot have constructors that overlap in their runtime representation, and reassures the reader that the compiler enforces it. Neither is true for one specific, legal, and fairly common shape.

apps/docs/markdown-pages/docs/manual/variant.mdx:382-389 — "Advanced: Unboxing rules / No overlap in constructors":

A variant can be unboxed if no constructors have overlap in their runtime representation.

For example, you can't have String1(string) | String2(string) in the same unboxed variant, because there's no way for ReScript to know at runtime which of String1 or String2 that string belongs to, as it could belong to both.
The same goes for two records - even if they have fully different shapes, they're still JavaScript object at runtime.

Don't worry - the compiler will guide you and ensure there's no overlap.

apps/docs/markdown-pages/syntax-lookup/decorator_unboxed.mdx:10:

The @unboxed decorator provides a way to unwrap variant constructors that have no overlap in their runtime representation, or record objects that have a single field.

What's actually true

The type-level rule is correct — String1(string) | String2(string) really is rejected. But a constant constructor's runtime value overlapping a payload constructor's type is explicitly allowed:

@unboxed
type color =
  | @as("primary") Primary
  | @as("secondary") Secondary
  | Color(string)

Color("primary") and Primary are the same runtime value — the string "primary". The compiler accepts this declaration and does not warn. check_invariant in compiler/ml/ast_untagged_variants.ml:224-227 keeps the constant and block literal sets deliberately separate, so this never registers as a duplicate.

So a reader following the manual would conclude Color("primary") cannot happen. It can, it is legal, and it has a defined meaning that the manual never states.

The undocumented semantics

Declared literals are tested first; a payload constructor only receives values that are not a declared literal:

function colorName(value) {
  if (value === "primary" || value === "secondary") { return "not Color"; }
  else { return value; }
}

So colorName(Color("primary")) is "not Color" — the literal wins, and the Color branch is never reached for that value.

This is independent of both orderings. I checked all four combinations and every one selects the literal, emitting byte-identical JS:

result
payload declared first: Color(string) | @as("primary") Primary literal
literal declared first literal
payload arm first in the switch literal
literal arm first in the switch literal

It also holds when the literal has no arm at all and is reachable only through _.

@as is not required for this, which is worth stating explicitly — a bare constructor's runtime value is its own name, so this already overlaps:

@unboxed type t = Primary | Color(string)
// Color("Primary") matches as Primary

Suggested change

Keep the existing rule about payload types, then add the constant-vs-payload case rather than implying it cannot occur. Roughly:

  • Two payload constructors may not share a runtime type — this the compiler does enforce.
  • A constant constructor's value may coincide with a value a payload constructor can hold. This is allowed, and is often what you want when modelling "a few known values, plus an escape hatch".
  • When it happens, the constant constructor wins: matching tests the declared literals first, and a payload constructor only receives values that are not one of them. This does not depend on the order of constructors in the declaration or the order of arms in the switch.
  • A constructor without @as is represented by its own name, so it participates in this too.

The line "Don't worry - the compiler will guide you and ensure there's no overlap" is the one I'd most want changed, since it tells the reader not to reason about a case they do need to reason about.

Background

This is not hypothetical. rescript-lang/rescript#6950 was a miscompilation caused by the optimizer resolving such a match by constructor identity rather than by value, fixed in rescript-lang/rescript#8631.

There is a related open question on the compiler side about whether this overlap should be permitted at all — it is currently rejected for bool but accepted for string, int, float and bigint. See rescript-lang/rescript#8632. If that is resolved in favour of rejecting the overlap, this documentation change would need revisiting, so it may be worth settling that first.

Activity

  1. cknitt commented on Oct 12, 2026

    @cknitt
    Member

    Status update: rescript-lang/rescript#8632 was resolved by rescript-lang/rescript#8777 (merged 2026-10-11, in v13). The constant/payload overlap described here is intended, so the doc change suggested above can go ahead as written. The manual still has the old wording (variant.mdx "No overlap in constructors", decorator_unboxed.mdx line 10).

    What changed in the compiler (v13):

    • The boolean case now behaves like the others: @unboxed type t = | @as(true) A | B(bool) is accepted. Previously it was rejected with "At most one case can be a boolean type."
    • One case is still rejected: a bool payload when both @as(true) and @as(false) are cases, because the payload case could never match. The error is "Case B can never match: both true and false are already cases of this variant."

    What the docs should say, refining the suggestion above:

    1. Two payload constructors can't share a runtime type, e.g. S1(string) | S2(string), or two records. The compiler rejects this.
    2. A constant constructor's value may coincide with a value a payload constructor can hold, e.g. @as("primary") Primary | Color(string). This is allowed, and is useful for "a few known values plus an escape hatch".
    3. When it happens, the constant wins. Literals are tested first, so the payload constructor only receives the other values. This is independent of declaration order and of switch arm order. A value built as Color("primary") matches as Primary, and Color("primary") == Primary is true.
    4. A constructor without @as is represented by its own name, so Primary | Color(string) overlaps on "Primary".
    5. For bool (v13+), partial overlap works the same way. A bool payload is rejected when both true and false are constant cases.
    6. Replace "Don't worry - the compiler will guide you and ensure there's no overlap" with a short note covering points 2–3. Adjust "no overlap" in decorator_unboxed.mdx to "no overlap between payload types".

    The compiler doesn't currently warn about a switch arm such as Color("primary") => ... that can never match. The docs could mention that such arms are dead.

    Versioning: points 1–4 apply to v11/v12 as well; point 5 only to v13.

  2. added this to the v13 milestone on Nov 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions