Skip to content

Make variant runtime representation canonical across compiler IRs - #8579

Merged
cristianoc merged 7 commits into
masterfrom
constructor-representation-cleanup
Aug 25, 2026
Merged

Make variant runtime representation canonical across compiler IRs#8579
cristianoc merged 7 commits into
masterfrom
constructor-representation-cleanup

Conversation

@cristianoc

Copy link
Copy Markdown
Collaborator

Summary

This PR gives nominal variants one canonical runtime representation and makes compiler phases consume that representation instead of independently reconstructing it from constructor ordinals, type lookups, and attributes.

The resulting design separates three concepts:

  • the source annotations that configure a variant;
  • the immutable, declaration-level runtime layout derived from them;
  • the occurrence-specific plan for compiling a particular pattern match.

Generated JavaScript and language behavior remain unchanged.

Rationale

Variant representation was previously distributed across several mechanisms:

  • positional constructor integers were carried through Typedtree, Lambda, Lam, and JS IR;
  • construction, pattern matching, coercion, printing, diagnostics, and GenType interpreted representation attributes independently;
  • matching and exhaustiveness logic repeatedly looked declarations up in the typing environment;
  • declaration-level runtime facts and the control-flow strategy for an individual match were conflated.

Constructor ordinals describe source ordering, not JavaScript values. That distinction matters for constructors customized with @as, unboxed payloads, booleans, options, lists, and other special representations.

Repeatedly interpreting attributes also permits compiler phases to disagree about the representation of the same declaration. Some representation facts require type expansion, so deriving them again later is both unnecessary and sensitive to which environment is available.

The representation is fundamentally a property of the variant declaration. It should therefore be computed once, after the recursive declaration group is available, and referenced directly by every consumer.

Design

Canonical declaration layout

Variant_runtime is introduced as a low-level module containing the plain data that describes the JavaScript representation of a nominal variant.

Type_variant stores a Variant_runtime.layout_ref. Recursive declarations allocate a pending reference while the group is provisional and complete that same reference exactly once after the group has entered the environment.

The completed immutable layout contains:

  • constructor representations in source order;
  • declaration-wide unboxing and custom tag-field configuration;
  • the facts needed to dispatch a constructor match.

Configuration that cannot be recovered from constructor shapes alone is retained explicitly. This matters, for example, for @unboxed or @tag on variants with no payload constructor. Predefined variants construct equivalent layouts directly.

Stable constructor references

Ordinary constructor descriptions reference their declaration's layout and their source position. This provides constant-time access to the canonical constructor representation without copying the layout or performing name-based declaration lookups.

Extension constructors remain identified by their extension path because they have different rebinding and runtime semantics.

Semantic runtime values instead of ordinals

Positional constructor integers are removed from the compiler IRs.

Lambda and Lam constants and blocks now carry their actual runtime descriptors. Constructors represented by numeric @as values become genuine integer constants, preserving constant folding without pretending that a source ordinal is the emitted value. JS block comparison and lowering use these runtime descriptors directly.

Declaration layout versus matching plan

A variant layout is declaration-level data. A matching plan depends on a particular match occurrence: its arms, actions, default case, exhaustiveness, and special-case opportunities.

Pattern matching therefore builds a local constructor_matching_plan with documented cases for:

  • a shared constructor action;
  • option/list payload-presence tests;
  • boolean tests;
  • general constructor switches.

The plan is immediately lowered to the existing Lambda control-flow forms. No new long-lived Lambda or Lam expression form is introduced, avoiding further divergence between those IRs. The general switch carries only the declaration-level matching_facts needed by later lowering.

Representation annotations become a typing-boundary concern

The canonical layout is built where the declaration's annotations are interpreted and validated.

Downstream consumers now read typed representation data:

  • construction and pattern matching;
  • exhaustiveness and constructor comparison;
  • variant coercion and inclusion;
  • type printing and diagnostics;
  • GenType generation;
  • type-based optimizations.

Legacy single-payload unboxing remains represented by Types.type_representation. Multi-constructor unboxing and constructor-specific representation are supplied by the variant layout. Record representation continues to use Types.record_representation.

GenType now distinguishes nominal variants from polymorphic variants explicitly: nominal variants use the canonical layout, while polymorphic variants continue to interpret their row-field annotations because they have no nominal declaration layout.

The pre-typing variant-spread compatibility check remains an annotation consumer because the destination declaration does not yet have a completed layout.

Explicit runtime declarations

The internal global -unboxed-types mode is removed. Runtime callback wrapper records now declare @unboxed explicitly, making their representation local and visible rather than dependent on compiler-wide state.

Additional cleanup

This representation makes several older mechanisms unnecessary:

  • constructor count and ordinal fields;
  • repeated environment lookups for declaration layouts;
  • the mutable matching-layout callback and its polyfill;
  • duplicated by-name layout maps;
  • stored representation facts derivable from the canonical layout;
  • Obj.magic forward references used by the previous derivation path;
  • the unclear Transparent terminology, replaced by Unboxed.

Compatibility

  • No source syntax changes.
  • No intended runtime or generated JavaScript changes.
  • No new Lambda or Lam expression language is introduced.
  • Parsetree0 remains unchanged for legacy PPX compatibility.
  • The removed -unboxed-types option was internal; its runtime uses are now explicit annotations.

Testing

  • opam exec -- dune build
  • make test
  • make test-gentype

The existing compiler, integration, runtime, formatting, error-output, and GenType suites pass without expected-output changes.

cristianoc and others added 7 commits August 25, 2026 09:04
Constructors no longer carry positional integer tags anywhere in the
compiler. Types.constructor_tag (Cstr_constant/Cstr_block of int) is
replaced by a semantic identity (declaring type path + name for ordinary
constructors, path for extensions), and every consumer now answers its
actual question directly:

- Parmatch compares constructors by identity and derives completeness
  from the type declaration instead of forging tags from counts; column
  coherence compares declared head types instead of count equality.
- Matching keys constructor switches by canonical constructor cases and
  takes case counts from the variant layout in scope; the
  layout_from_construct_pattern mutable callback is inlined as a plain
  function and its polyfill removed.
- Construction Lambda carries canonical runtime descriptors only:
  Const_pointer loses its ordinal, Lam_constant gains a first-class
  Const_constructor, and the tag ints are removed from Blk_constructor,
  Blk_record_inlined, Record_inlined, Lam.Pmakeblock, Const_block, and
  J.Caml_block. Constructors represented as numbers (@as(Int)) convert
  to genuine int constants so folding is preserved; JS block equality
  compares runtime descriptors instead of ordinals.
- cstr_consts/cstr_nonconsts are removed; transparency is minted once in
  datarepr as cstr_transparent; Blk_constructor.num_nonconst is sourced
  from the declaration via the constructor identity.
- unboxed_status collapses to type_representation = Boxed | Transparent;
  the internal -unboxed-types flag is removed and the runtime's
  Primitive_js_extern.res declares its unboxed records explicitly.

Emitted JavaScript is unchanged across the test suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWw5GW8t4UDEWAzoqcDMkE
Introduce Variant_runtime, a leaf module below Types holding the plain
data that describes how variants are represented in JavaScript;
Ast_untagged_variants re-exports the definitions and keeps deriving
them. Type_variant now carries the canonical layout, mirroring how
Type_record carries record_representation: typedecl computes it once the
recursive group is in the environment, at the same point the untagged
invariants were already being validated by computing this exact layout
and discarding it. Predefined declarations mint their layouts by hand,
and the untagged helper refs are installed from Typedecl so every binary
that types code has them.

The stored layout is not consumed yet; matching still derives its own.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWw5GW8t4UDEWAzoqcDMkE
Constructor descriptions now carry their declaring variant's layout,
minted in datarepr from the declaration, and every consumer reads it
instead of re-deriving representation facts:

- Matching takes the layout straight from the constructor description;
  the per-switch type resolution and the sw_layout plumbing are gone,
  and sw_dispatch is the layout's precomputed dispatch.
- Translcore counts payload constructors from the stored layout instead
  of looking the declaration up in the environment.
- Parmatch reads a constructor's untagged block type from the stored
  layout instead of re-resolving the declaration.

With typedecl the only remaining layout computer, the derivation
(get_block_type and friends) moves to a new Variant_layout module above
Ctype, and the Obj.magic forward references in Ast_untagged_variants are
deleted: typing a declaration now determines its representation once,
and it is never revisited.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWw5GW8t4UDEWAzoqcDMkE
The Ordinary_constructor payload duplicated information already on the
description: its name field mirrored cstr_name (read only by
same_constructor, which has the whole description in hand), and its
type_path had no remaining reader once the stored layout replaced the
declaration lookups. constructor_identity becomes constructor_kind —
Ordinary_constructor | Extension_constructor of Path.t — and identity is
the pair of cstr_kind and cstr_name, or the extension's path. This also
removes the unenforced invariant that the identity's name matched
cstr_name, and makes the wrong comparison (ordinary constructors by
path, which re-exports would break) inexpressible.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWw5GW8t4UDEWAzoqcDMkE
Review of the representation changes found derived data stored beside
its source and duplicate derivations:

- variant_layout collapses to the constructors array. The by-name map
  duplicated every case (and the names inside them), and the dispatch
  field was derivable; both are now computed by accessors, with the
  dispatch derived at its single consumer in Matching.
- cstr_transparent was derivable from cstr_layout plus the unboxed
  attribute once descriptions carried their layout; it is a Datarepr
  predicate again, now environment-free.
- Construction in Translcore reads the constructor's layout entry
  (Datarepr.constructor_case) instead of re-deriving the tag and block
  runtime from attributes, closing the last spot where construction and
  matching could derive representation independently.
- Parmatch's full_match compares against the layout's length instead of
  looking the declaration up in the environment; the block-count folds
  in Translcore, Matching, and Datarepr use one Variant_runtime helper;
  js_dump drops a tautological num_nonconst test.
- The type-equation re-exports in Ast_untagged_variants are gone:
  consumers reference Variant_runtime directly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWw5GW8t4UDEWAzoqcDMkE
The runtime representation of a variant is declaration-level data, while the decision for a particular match depends on its arms, actions, default, and exhaustiveness. Represent the two separately:

- Variant_runtime.layout stores immutable constructor representations and declaration-level matching facts computed once.
- Type_variant stores an abstract one-shot layout_ref. Recursive declarations allocate it while provisional, then complete the same identity after the recursive group is available.
- Ordinary constructor descriptions address their representation by layout reference and source position, removing repeated name lookup and duplicated layout storage.
- Matching builds one occurrence-specific constructor_matching_plan in combine_constructor and immediately lowers it to existing Lambda forms. No Lam or Lambda expression form is added.
- Construction, matching, and type-based optimization consume the canonical representation instead of reinterpreting runtime attributes.

Rename Transparent to Unboxed and variant_dispatch to matching_facts so the remaining terms describe the represented facts rather than an implementation strategy.

Signed-off-by: Cristiano Calcagno <cristianoc@users.noreply.github.com>
Variant representation annotations are syntax-level inputs, but their meaning is needed throughout type inclusion, coercion, printing, diagnostics, and GenType. Previously those consumers independently re-read attributes from typed declarations and constructors, leaving both the annotations and their interpreted representation live in later compiler phases.

Build the canonical declaration configuration together with the variant layout after the recursive declaration group has entered the environment. Retain the declared unboxing bit and custom tag field even for shapes, such as nullary-only variants, from which those choices cannot be recovered by inspecting constructor blocks. Constructor tags and unboxed payload facts remain indexed by source position in the same completed layout.

Migrate Ctype coercions, inclusion checks, Printtyp, error suggestions, and GenType to consume the typed layout and the existing type/record representations instead of parsing attributes. This also makes GenType distinguish nominal variants from polymorphic variants explicitly: nominal cases use canonical constructor tags, while polymorphic variants continue to interpret their own row-field annotations.

Keep attribute interpretation only at the typing boundary that creates the layout and in the pre-typing variant-spread compatibility check, where no completed target layout exists yet. Preserve legacy single-payload unboxing through Types.type_representation and combine it with declaration-level layout configuration when an effective runtime configuration is required.

Signed-off-by: Cristiano Calcagno <cristianoc@users.noreply.github.com>
@cristianoc
cristianoc force-pushed the constructor-representation-cleanup branch from ed49110 to af49658 Compare August 25, 2026 11:01

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ed491104b2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

~type_representation:decl.type_representation ~layout
in
configuration.tag_name
| Type_abstract | Type_record _ | Type_open -> None

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve tag metadata for abstract signature types

When a .resi intentionally hides a tagged variant as @tag("kind") type t and its .res defines the same tagged variant, this branch returns None for the abstract signature declaration while returning Some "kind" for the implementation layout. The comparison below consequently emits Tag_name despite matching annotations; the previous implementation read type_attributes for abstract declarations, so this valid abstraction no longer compiles. Preserve the source tag configuration for abstract types and cover this .res/.resi case with a multi-file fixture.

AGENTS.md reference: AGENTS.md:L181-L183

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This does identify a behavior change relative to master, but I do not think we should preserve it by attaching variant representation metadata to abstract types.

An abstract type exposes no constructors, so @tag does not change anything clients can construct or pattern-match. It also cannot be used in a variant spread: Variant_type_spread rejects Type_abstract (covered by the existing variant_spread_abstract_type fixture). The unsafe case is a concrete signature that exposes constructors while hiding or changing its tag field; that must remain rejected, and concrete variants retain their canonical layout here.

There is already an asymmetry in the old inclusion code: unboxing comparison is skipped when the expected declaration is abstract, while tag-name comparison is unconditional. The accepted example therefore looks like an accidental representation constraint on an otherwise abstract type, rather than an intentional abstraction feature.

The principled rule should be that concrete variants expose and compare their runtime layout, while abstract types carry no variant layout and hide representation. We should either ignore @tag when including into an abstract declaration, consistently with unboxing, or reject @tag on an abstract declaration as meaningless. Storing a tag configuration solely to preserve this non-operational constraint would reintroduce representation information in the wrong place.

@cristianoc
cristianoc requested a review from cknitt August 25, 2026 11:09
@codecov

codecov Bot commented Aug 25, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 83.21871% with 122 lines in your changes missing coverage. Please review.
✅ Project coverage is 75.92%. Comparing base (fe15b97) to head (af49658).
⚠️ Report is 1 commits behind head on master.

Files with missing lines Patch % Lines
compiler/core/lam.ml 52.08% 23 Missing ⚠️
compiler/ml/parmatch.ml 71.79% 11 Missing ⚠️
compiler/core/lam_print.ml 16.66% 10 Missing ⚠️
compiler/ml/printlambda.ml 0.00% 10 Missing ⚠️
compiler/core/js_exp_make.ml 33.33% 6 Missing ⚠️
compiler/ml/typeopt.ml 72.72% 6 Missing ⚠️
compiler/core/js_of_lam_variant.ml 20.00% 4 Missing ⚠️
compiler/core/lam_analysis.ml 33.33% 4 Missing ⚠️
compiler/gentype/translate_type_declarations.ml 86.66% 4 Missing ⚠️
compiler/ml/variant_layout.ml 92.00% 4 Missing ⚠️
... and 20 more
Additional details and impacted files
@@            Coverage Diff             @@
##           master    #8579      +/-   ##
==========================================
- Coverage   75.95%   75.92%   -0.03%     
==========================================
  Files         474      476       +2     
  Lines       62907    63007     +100     
==========================================
+ Hits        47781    47839      +58     
- Misses      15126    15168      +42     
Files with missing lines Coverage Δ
analysis/reanalyze/src/dead_type.ml 90.56% <100.00%> (ø)
analysis/reanalyze/src/dead_value.ml 85.77% <100.00%> (ø)
analysis/src/hover.ml 76.12% <100.00%> (ø)
analysis/src/process_cmt.ml 82.00% <100.00%> (ø)
compiler/bsc/rescript_compiler_main.ml 71.49% <ø> (-0.14%) ⬇️
compiler/core/bs_conditional_initial.ml 100.00% <ø> (ø)
compiler/core/j.ml 100.00% <ø> (ø)
compiler/core/js_analyzer.ml 81.57% <100.00%> (ø)
compiler/core/js_dump.ml 87.23% <100.00%> (-0.07%) ⬇️
compiler/core/js_of_lam_block.ml 100.00% <100.00%> (ø)
... and 63 more

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@pkg-pr-new

pkg-pr-new Bot commented Aug 25, 2026

Copy link
Copy Markdown

Open in StackBlitz

rescript

npm i https://pkg.pr.new/rescript-lang/rescript@8579

@rescript/belt

npm i https://pkg.pr.new/rescript-lang/rescript/@rescript/belt@8579

@rescript/darwin-arm64

npm i https://pkg.pr.new/rescript-lang/rescript/@rescript/darwin-arm64@8579

@rescript/darwin-x64

npm i https://pkg.pr.new/rescript-lang/rescript/@rescript/darwin-x64@8579

@rescript/linux-arm64

npm i https://pkg.pr.new/rescript-lang/rescript/@rescript/linux-arm64@8579

@rescript/linux-x64

npm i https://pkg.pr.new/rescript-lang/rescript/@rescript/linux-x64@8579

@rescript/runtime

npm i https://pkg.pr.new/rescript-lang/rescript/@rescript/runtime@8579

@rescript/win32-x64

npm i https://pkg.pr.new/rescript-lang/rescript/@rescript/win32-x64@8579

commit: af49658

@github-actions

Copy link
Copy Markdown

@cknitt cknitt 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.

Great cleanup! Tested against a large project, no output diffs.

@cristianoc
cristianoc merged commit 59b4f43 into master Aug 25, 2026
29 checks passed
@cristianoc
cristianoc deleted the constructor-representation-cleanup branch August 25, 2026 18:39
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.

2 participants