Skip to content

RFC: Objects from first principles — end-to-end representation cleanup #8584

Description

@cristianoc

This is the design for a first-principles cleanup of the structural object representation, end to end through the compiler layers — a follow-up to the FFI expansion-at-translation series (#8581/#8582). The full design document follows below; implementation PRs will land in stages and link back to this issue.

Highlights:

  • The current encoding (writability as a phantom "x#=" row member) permits unrelated getter and setter types on one property, which is a demonstrated type-preservation failure (§2.7: a binding declared int evaluates to "hello" at runtime, from honest declarations). The proposed representation makes that state inexpressible.
  • Mutability becomes a two-value flag on object row fields with one new operation (promotion on open rows), matching today's observable behavior everywhere except the soundness fix and two explicitly removed attribute forms.
  • Object literals stop being typed through a synthetic letmodule-wrapped external and become first-class nodes; Lsend/Pjs_unsafe_downgrade disappear, converging Lambda and Lam further.

Stages

  • Behavior-pinning tests (first PR: pins today's semantics, marks the two cases that intentionally flip)
  • Stage 0 — remove obsolete field attributes (@set({no_get}), @get(null|undefined|nullable))
  • Stage A — mechanical deletions (dead Tobject memo, Texp_send vestiges, frontend ##)
  • Stage B — Lambda/Lam convergence for property access (Lsend → shared Pjs_object_get/Pjs_object_set)
  • Stage C — direct object literals
  • Stage D — explicit object-field mutability (the soundness fix; kills #= everywhere)
  • Stage E — optional simplifications (Tpoly wrapping, field_kind lattice)

Objects, from first principles: end-to-end representation cleanup

Design document (also tracked in the implementation branch as docs/object_representation_cleanup.md). This document
guides the implementation; the implementation PRs follow it in stages.

Guiding principle (same as the FFI series): reach the end state the compiler
would have if objects were designed for ReScript from scratch — no string
encodings, no phantom declarations, each layer producing its final form at the
point where the information originates.

Second principle: preserve current observable behavior except for the
explicitly listed removals and soundness corrections
(Stage 0 and §2.7).
When a representational choice is ambiguous, use current compiler behavior as
the tiebreaker and verify it with a focused compiler probe.

The overall direction is settled. Concrete names, constructor shapes, and
stage boundaries are current proposals to validate during implementation.
The document records the rationale for deliberate choices so later revisions
can distinguish decisions from placeholders.

1. Supported object features

ReScript structural object types are used primarily for JavaScript interop.
The supported source-language features are:

  1. Object literals: {"a": 1}
  2. Object types: {"a": int}, open {.. }, spread {...t, "b": string}
    (Oinherit), and the field attribute @set (mutability).
    Two further attribute forms exist today but are removed in Stage 0: the
    write-only variant @set({no_get: true}) and the getter-result wrappers
    @get(null|undefined|nullable).
  3. Property access: obj["x"] and assignment obj["x"] = v
  4. Width subtyping via coercion: (o :> {"y": string})
  5. @obj externals (e.g. Stdlib_Dict.make)

Records remain the recommended representation for application data. Structural
objects remain necessary for untyped JavaScript interop.

The implementation is inherited from OCaml's class subsystem. Class syntax,
class types, and Pexp_object have already been removed, but substantial
supporting representation remains.

2. Current representation, layer by layer

2.1 Parser (compiler/syntax/src/res_core.ml)

  • obj["x"]Pexp_send(obj, "x") (res_core.ml:2229).
  • obj["x"] = v → application of a synthetic #= operator:
    Pexp_apply(Lident "#=", [Pexp_send(obj, "x"); v]) (res_core.ml:2240). The
    printer resugars it back (res_printer.ml:4302, 4587).
  • {"a": 1}Pexp_extension(%obj, record with quoted labels)
    (res_core.ml:3544).
  • Object types → Ptyp_object with Otag/Oinherit fields; spread ...t
    becomes Oinherit (res_core.ml:5903).

2.2 Frontend (compiler/frontend/)

  • Setter mangling (ast_exp_apply.ml:170-198): the #= application is
    rewritten to a method call on a phantom method with a mangled name:
    obj # "x#=" (v) constrained to unit. "#=" is
    Literals.setter_suffix.
  • Type-side mangling (ast_core_type_class_type.ml:54): to make the above
    typecheck, an object-type field @set "x": int is expanded into two row
    members
    : x: int and x#=: int => unit. Mutability is thus encoded as
    the existence of a second member with a mangled name. The same machinery
    (ast_attributes.ml:process_method_attributes_rev) also implements the
    obsolete forms: @set({no_get: true}) suppresses the getter member
    (write-only field — used by exactly one test, mutable_obj_test.res), and
    @get(null/undefined/nullable) wraps the field type in
    Js.null/undefined/nullable. Both are removed, not redesigned (§4
    Stage 0).
  • Literal synthesis (ast_util.ml:record_as_js_object,
    via ast_exp_extension.ml:66): %obj is expanded into a synthetic local
    external inside a generated letmodule
    :
    let module J = struct external unsafe_expr : (~a:'a) => <obj> = "" end in J.unsafe_expr(~a=1). The literal is typed via this fabricated labeled arrow
    type, not directly as an object type, and reaches the backend through
    Ffi_obj_create.
  • ## remnant (ast_exp_apply.ml:135): the ## operator is handled here
    but the ReScript parser never produces it — reachable only from
    PPX-manufactured ASTs. This BuckleScript-era handling is deleted; no PPX is
    believed to interpret the object encodings, and the v0 bridge makes deletion
    safe regardless (§3.6).

2.3 Type checker (compiler/ml/)

The required structural core consists of Tobject rows built from
Tfield/Tnil, open-row unification, and width subtyping in ctype.ml.
Around it:

  • Tobject of type_expr * (Path.t * type_expr list) option ref
    (types.ml:31): the second component is the class-abbreviation memo (#c
    naming). No reachable path can construct the abbreviation state: every
    creation site builds ref None, and the surviving writers only propagate or
    clear an existing Some (set_name at ctype.ml:2280 copies !nm1;
    ctype.ml:3299-3301 rebuilds from a contents = Some matched upstream at
    3282; normalize at ctype.ml:4059-4074 rewrites or clears an existing
    Some). The creators lived in the removed class-abbreviation machinery, so
    by induction the ref is always None; the readers (printtyp.ml:694, the
    subtype walk, ctype.ml:592, 1308) and propagating writers are dead with it.
  • field_kind = Fvar | Fpresent | Fabsent (types.ml:62): designed for OCaml
    method hiding. User syntax only constructs Fpresent (typetexp.ml:625), but
    the lattice is still exercised internally: typing obj["x"] against {..}
    creates an Fvar field (filter_method), and row-closing changes it to
    Fabsent (ctype.ml:2295). These states implement field-presence negotiation
    for open rows and are handled at approximately 26 unification, copying, and
    substitution sites.
  • Tpoly wrapping: every object field type is wrapped in Tpoly(ty, [])
    (typetexp.ml:transl_fields via transl_poly_type) and Pexp_send typing
    unwraps it, although the parser does not produce Ptyp_poly for object
    fields and therefore cannot declare polymorphic object methods.
  • Texp_send of expression * meth * expression option (typedtree.ml:126): the
    third field is always None — typecore.ml:3303-3309 is a degenerate
    match obj.exp_desc with | _ -> producing it, next to an unused
    obj_meths = ref None. meth = Tmeth_name of string is a
    single-constructor wrapper; OCaml's other constructors were removed with
    classes.
  • Assignment typing depends on the #= members: filter_method finds the
    mangled member in the row. The encoding therefore affects unification,
    signature inclusion, and serialized cmis.
  • User-visible leak: subtype errors print the phantom member —
    Type t = {"x": int, "x#=": int => unit} is not a subtype of …. The
    outcome printer (res_outcome_printer.ml:342 print_object_fields) does not
    resugar it to @set.
  • gentype special case: gentype/runtime.ml:38
    check_mutable_object_field checks the #= suffix to reconstruct
    mutability.

2.4 Translation (compiler/ml/translcore.ml)

  • Texp_send(e, Tmeth_name nm, _)Lsend(nm, obj, loc) (translcore.ml:1221)
    — a dedicated Lambda node holding a string and one subterm. No dispatch, no
    self, and no method table remain; this is property access represented as a
    method call.
  • Setters arrive as Texp_apply { funct = Texp_send(…, "x#=") } and translate
    to Lapply { ap_func = Lsend … }; the name suffix is the only indication
    that the operation is a setter at this point.

2.5 Lambda → Lam convert (compiler/core/lam_convert.ml:336-351)

  • Recognizes Lapply { ap_func = Lsend(name, …) } by the "#=" suffix, strips
    it with String.sub, and produces
    Pjs_unsafe_downgrade {name; setter = true}; bare Lsend becomes
    setter = false. This is a remaining string-based encoding and a difference
    between Lambda and Lam: Lsend exists only in Lambda, while
    Pjs_unsafe_downgrade exists only in Lam.
  • The primitive name originates in the BuckleScript implementation of
    obj##x, which was typed via
    Js.unsafe_downgrade : Js.t<'a> => 'a. "Downgrade" referred to unwrapping
    the since-removed Js.t wrapper, and "unsafe" referred to bypassing OCaml
    method dispatch. The primitive now represents plain property access.

2.6 Backend (compiler/core/lam_compile.ml:1769-1815)

  • Getter emits E.dot obj property; setter emits
    E.assign (E.dot obj property) value; unit. Correctly classified as
    effectful (lam_analysis.ml:96 — property accessors can run code).
  • Object creation arrives as Pjs_object_create (via Ffi_obj_create from
    the synthesized external) and emits an object literal.

2.7 The encoding permits inconsistent property types

Because writability is an independent row member, nothing relates a
property's read type to its write type, while the backend compiles both
members to the same JavaScript property. The following was compiled with the
current bsc and executed under Node:

let breakSoundness = (o: {.."x": int}): int => {
  o["x"] = "hello" // adds "x#=": string => unit to the open row
  o["x"] // reads "x": int — same storage
}

/* Backed by the JavaScript object {x: 1}. */
@val external po: {.."x": int} = "po"

let result: int = breakSoundness(po)

The emitted JavaScript is o.x = "hello"; return o.x;. At runtime, result
is the string "hello" even though its declared type is int. Assignment
adds the setter member from the right-hand side's type without comparing it
with the getter type. A coercion reaches the same inconsistent state through
open-row extension during subtyping: with wide <: narrow,
({.. "x": wide} :> {@set "x": narrow}) compiles today, leaving getter
wide and setter narrow on one property.

This is a type-preservation failure for ordinary JavaScript data properties:
a write accepted at one type invalidates a read guarantee for the same
property. It is not a garbage-in problem with the external: fabricating a
getter through the open row (po["y"] : string also compiles) produces a
wrong conclusion only about the fabricated member itself, while the setter
member reaches through the shared storage into the honestly typed member
"x" — a false conclusion derived entirely from true premises. Pure
ReScript literals cannot construct setter-bearing object values; the
reproducer enters through the JavaScript interop boundary, which is the
primary use of structural objects. The required invariant is one
property, one storage location, one type
. The proposed representation
enforces that invariant (§3.2 and §3.3).

A related encoding problem is that the mangled namespace is unguarded. A
user-written field literally named "x#=" is the setter for "x" today: with
o: {"x": int, "x#=": string => unit}, the assignment o["x"] = "hello"
typechecks even on this closed row.

3. Proposed representation

The end state has one object type representation, distinct get and set nodes,
and one object-creation primitive.

3.1 Object rows and fields

Tobject becomes Tobject of type_expr; the dead class-abbreviation field is
removed. Each row field has a two-value mutability flag:

type mutable_flag = Immutable | Mutable

| Tfield of {
    name: string;
    presence: field_kind;
    mutability: mutable_flag;
    typ: type_expr;
    rest: type_expr;
  }

Openness remains a property of the object row, while mutability is a property
of each known field. There is no per-field "unknown mutability" state — such
a state could also represent an undetermined field in a closed row, which
today's semantics cannot express. Today,
an absent setter on an open row means that writability has not yet been
required; an absent setter on a closed row means that the field is read-only.
The new representation preserves that distinction through row openness.

An @set field in an object type is Mutable. A field without @set is
Immutable; this includes inferred object-literal fields and fields created
by getter lookup on an open row. Getter and setter operations on a Mutable
field always use the same typ. The inconsistent getter/setter state described
in §2.7 is intentionally not representable.

The Immutable/Mutable names match the established record-field
terminology. They describe whether assignment is permitted by the type, not
whether the underlying JavaScript object can be changed by untyped code.

Phantom #= members disappear from rows, cmis, diagnostics, and gentype.
Removing the Tpoly wrapper from field types and reducing field_kind are
possible follow-up simplifications rather than requirements of this stage
(§4 Stage E and §6).

3.2 Promotion, assignment, and inference

Promotion changes Immutable A to Mutable A. It preserves the field type:

Immutable A -> Mutable A

Unification may promote a field only when that field belongs to an open row.
The same rule types assignment. On a closed row, assigning to an Immutable
field is an error. Promotion is monotone and must use the existing
backtracking and level mechanisms used for row extension.

This preserves the current behavior in which writing a known field of an open
row adds a setter requirement to the row. It deliberately changes the current
behavior that can add a setter at a type unrelated to the getter. Under the
new representation, assigning a string to an open field already known as
int fails instead of producing a getter of int and setter of string.

let readX = obj => obj["x"] continues to infer an open object row. The row
variable is generalized and freshly instantiated at each call, so the
function can be applied to both read-only and mutable objects. In
(obj, value) => obj["x"] = value, assignment promotes the field while typing
the body, and the inferred parameter consequently requires a mutable field.

Copying, instantiation, equality checks, signature inclusion, serialization,
and printing must all preserve the mutability flag. The exact signature
inclusion behavior remains an implementation validation item (§6.2).

3.3 Subtyping

For known fields on closed rows, with A and B as field types:

Source Target Condition
Immutable A Immutable B A <: B
Mutable A Immutable B A <: B; the target forgets write permission
Immutable A Mutable B rejected
Mutable A Mutable B A <: B and B <: A; mutable fields are invariant

These rules preserve current closed-row behavior. In particular,
({@set "x": int} :> {"x": int}) compiles today and remains valid.

Row openness adds the following rules. In the examples, assume wide <: narrow, such as {"a": int, "b": int} <: {"a": int}:

  • Open source, mutable target: if the source field is immutable, promote
    Immutable A to Mutable A; then require A and B to be equivalent. Thus
    ({.. "x": wide} :> {@set "x": wide}) remains valid, while
    ({.. "x": wide} :> {@set "x": narrow}) becomes an error.
  • Open source, closed immutable target: compare the field covariantly.
    The result is read-only, so later promotion of the source cannot introduce
    writes through the result.
  • Both rows open: compare known fields invariantly. An open result can
    later be promoted; covariance would allow a write through the narrowed view
    to invalidate reads through the original view. This preserves the current
    rejection of ({.. "x": wide} :> {.. "x": narrow}), implemented today by
    the equality branch at ctype.ml:3760 ("Same row variable implies same
    object").
  • Closed source, open target: current subtyping instantiates the target
    tail from the closed source, so the result is closed and cannot later be
    promoted. Known fields therefore follow the closed-row table above; in
    particular, immutable target fields remain covariant.

The governing condition is that covariance is allowed only when the coercion
result cannot later acquire write permission. Target-row openness does not by
itself grant or require write permission.

Rejecting different getter and setter types is a user-visible soundness
correction. The changelog should describe it as closing the type-preservation
failure in §2.7.

3.4 AST and IR nodes

The proposed names use separate getter and setter nodes because their
arities, effects, typing rules, and result types differ:

(* Parsetree *)
| Pexp_object_literal of (label loc * expression) list
| Pexp_object_get of expression * label loc
| Pexp_object_set of expression * label loc * expression

(* Typedtree *)
| Texp_object_literal of (label loc * expression) list
| Texp_object_get of expression * label loc
| Texp_object_set of expression * label loc * expression

(* Lambda and Lam *)
| Pjs_object_create of js_object_property list
| Pjs_object_get of string
| Pjs_object_set of string

The send terminology is removed because these nodes represent JavaScript
property operations, not method dispatch. The Pjs_ prefix matches
Pjs_object_create and identifies JavaScript-specific operations.

3.5 Object creation

Object literals and @obj externals share a semantic creation payload rather
than exposing External_arg_spec in the IR:

type js_object_property = {name: string; emission: property_emission}
and property_emission =
  | Always
  | If_present

Always covers literal fields and ordinary @obj arguments. If_present
omits an optional @obj property when the ReScript optional argument is
absent.

Pjs_object_create receives one value, already converted to its JavaScript
representation, per property. Translation sequences effects from ignored or
unit arguments outside the primitive, materializes @as constants as values,
performs FFI conversions before creation, and represents optional presence
separately from the converted value. In particular, If_present depends on
whether the ReScript optional is present, not on whether the converted value
is undefined; nested options make that distinction observable. All
obj_arg_type variants reachable through @obj (including @string/@int
polyvar conversions) were probe-verified to already behave this way.

Object literals are typed directly as closed object rows and translated to
Pjs_object_create. The %obj extension, record_as_js_object, and synthetic
let-module/external expansion are removed. Ffi_obj_create remains for user
@obj externals.

3.6 Frozen parsetree bridge

parsetree0.ml remains unchanged. The v0 bridge must round-trip the new forms
losslessly using the existing encodings:

Current parsetree v0 encoding
Pexp_object_literal %obj extension over a record
Pexp_object_get Pexp_send
Pexp_object_set #= application over Pexp_send

Generic PPX traversal therefore continues to work, and ast_mapper_from0
reconstructs the new nodes before frontend processing.

3.7 Result by compiler layer

  • Frontend: bare @set sets the mutability flag. Stage 0 removes the two
    obsolete object-field attribute forms. %obj, record_as_js_object, and
    frontend ## handling are removed.
  • Type checker: assignment uses the field's mutability flag and the
    promotion rules above. No phantom setter member is created.
  • Translation: Texp_object_get and Texp_object_set translate directly
    to Pjs_object_get and Pjs_object_set, which are identical in Lambda and
    Lam. Lsend, its conversion cases, suffix recognition, and
    Pjs_unsafe_downgrade are removed.
  • Backend: emitted JavaScript remains obj.x, obj.x = value, and object
    literals. Only the matched primitive names and payloads change.

4. Staged plan

The stages are largely independent. Stages 0, A, and B are small mechanical
changes; Stage C adds direct literal typing; Stage D contains the largest
semantic change.

Stage 0 — remove obsolete field attributes

  • @set({no_get: ...}) and @get(null|undefined|nullable) on object-type
    fields become errors with a clear message (bare @set stays). Delete their
    processing in ast_attributes.ml / ast_core_type_class_type.ml; update or
    remove the affected fixtures (mutable_obj_test.res for no_get). A
    nullable getter type is written explicitly as null<int>,
    undefined<int>, or nullable<int>.
  • Changelog entry as a breaking change. Doing this first means Stages D/E
    design against the trimmed surface only.

Stage A — mechanical deletions

  • Delete the second component of Tobject. No creator of its Some state
    remains (§2.3), so its readers and propagation-only writers are also
    removed.
  • Texp_send third field → delete; Tmeth_name wrapper → inline as
    string; remove the degenerate match and unused obj_meths state in
    typecore.
  • Delete the frontend ## handling (ast_exp_apply.ml:135): unreachable
    from the parser; no PPX is believed to produce it, and the v0 bridge in
    §3.6 carries the supported encodings regardless.
  • Apply the Stage A serialization changes listed in §5.1.

Stage B — Lambda/Lam convergence for property access

  • Move setter recognition from lam_convert to translcore: translation
    pattern-matches Texp_apply {funct = Texp_send(…, name)} with the suffix
    and emits the shared primitive; bare sends emit the getter primitive.
  • Introduce Pjs_object_get/Pjs_object_set identically in lambda.ml and
    lam_primitive.ml; delete Lsend from Lambda and the two convert cases;
    delete Pjs_unsafe_downgrade.
  • Note: the #= string channel still exists (frontend → typechecker →
    translation) after this stage and remains until Stage D. This stage is part
    of the remaining Lambda/Lam convergence work.
  • See §5.1 for this stage's cmj schema implications.

Stage C — direct object literals

  • Type {"a": 1} directly: the parser produces Pexp_object_literal;
    typecore types it as a closed object row (Texp_object_literal); translcore
    emits Pjs_object_create.
  • Delete %obj expansion, record_as_js_object, and the letmodule/external
    synthesis. Note literals have no optional/@as machinery to preserve —
    pval_prim_of_labels builds plain labels only (obj_arg_type = Nothing);
    optionals/@as belong to @obj externals and stay there.
  • Preserve and test these literal semantics: ordered property names;
    source-order, exactly-once evaluation of field expressions; punning;
    duplicate-name behavior and its diagnostic; and inferred literal fields as
    Immutable. Use the semantic Pjs_object_create payload from §3.5; do not
    expose External_arg_spec.obj_params in parsetree or typedtree nodes.
  • Parsetree0 mapping: Pexp_object_literal ↔ the reserved %obj extension
    shape (§3.6).
  • Tooling consumers of the new nodes: ast_mapper/ast_iterator (and the v0
    mappers), res_printer, res_comments_table, printast/pprintast,
    depend, and analysis — completion special-cases %obj today
    (analysis/src/completion_front_end.ml:1233) and must handle the literal
    node instead. Add analysis/syntax tests alongside.
  • Apply the Stage C serialization changes listed in §5.1.

Stage D — explicit object-field mutability

  • Add the two-value mutability flag and the promotion operation (§§3.1–3.2) to
    object row fields; type obj["x"] = v against them; stop generating
    phantom members in ast_core_type_class_type.
  • Land the structural access nodes: Pexp_object_get/Pexp_object_set and
    Texp_object_get/Texp_object_set replace Pexp_send + the #=-apply
    encoding (the v0 bridge maps them back to the legacy shapes, §3.6). Update the
    node consumers: ast_mapper/ast_iterator, v0 mappers, tast_mapper/
    iterators, res_printer, res_comments_table, printast/pprintast/
    printtyped, depend, rec_check, analysis completion, reanalyze.
  • Update unification, equality/generalization checks, copying, instantiation,
    signature inclusion, subtyping, and type printing according to §§3.2–3.3.
    printtyp and res_outcome_printer reconstruct @set directly from the
    flag.
  • Delete gentype's check_mutable_object_field suffix check and read the flag
    instead. Diagnostics no longer print phantom "x#=" members.
  • Apply the Stage D serialization changes listed in §5.1 and run the behavior
    tests listed in §5.3.

Stage E — optional simplifications (after D)

  • Drop Tpoly wrapping of field types (survey other Tpoly uses first;
    send-typing unwrap disappears).
  • Reduce the field_kind lattice to what {..} negotiation needs. This is a
    unification-algorithm change; only attempt with the object test surface
    covered by tests (§5.3).

5. Compatibility and testing

5.1 Serialization

The required changes are:

  • Stage A: cmi bump (Tobject shape) + cmt bump (Texp_send shape).
  • Stage B: Lam.t is serialized into cmjs (persistent_closed_lambda,
    js_cmj_format.mli:52), so Lam constructor changes alter the cmj schema.
    Cmjs carry no version constant (js_cmj_format.ml:29: TODO: add a magic number) and never have; this is safe in practice because cmjs are
    per-compiler-version build artifacts — a compiler update triggers a full
    rebuild (rewatch cleans on compiler change). Stage B relies on that, as
    every prior Lam change has; introducing a cmj magic is out of scope for
    this design.
  • Stage C: ast magic bump (new parsetree constructor) + cmt bump.
  • Stage D: ast bump (get/set parsetree nodes) + cmi bump (Tfield shape)
    • cmt bump.

5.2 Compatibility boundaries

  • The frozen parsetree must round-trip through the mappings in §3.6. No PPX is
    believed to interpret these object encodings semantically; generic traversal
    continues to see the existing v0 shapes.
  • Stage D replaces gentype's current #= suffix check with the mutability
    flag.
  • The Stage 0 removals and Stage D soundness correction require changelog
    entries.

5.3 Current-behavior regression tests

The following tests are added ahead of implementation (first PR). Comments identify the
cases that compile before Stage D and are expected to become errors afterward.

  • tests/tests/src/object_mutability_pin.res covers compiling behavior:
    closed mutable-to-immutable covariance; open-source-to-closed-target
    covariance; closed-source-to-open-target covariance; assignment and
    coercion that strengthen an open source's mutability requirement; and a
    generalized getter applied to both read-only and mutable objects. It also
    contains the two cases expected to become errors: the unequal-type coercion
    and the unrelated-type assignment from §2.7.
  • Super-error fixtures cover:
    • object_write_closed_row.res: assignment to a closed immutable field.
    • object_coercion_open_open.res: invariance when both rows are open.
    • object_coercion_readonly_to_mutable.res: closed immutable source to
      mutable target.
    • object_coercion_mutable_unequal.res: mutable-to-mutable invariance.
    • object_open_write_readonly_caller.res: assignment-driven strengthening
      rejects a read-only caller.
    • object_coercion_promote_readonly_caller.res: coercion-driven
      strengthening rejects a read-only caller.
    • object_write_after_open_target_coercion.res: coercion from a closed
      source to an open target produces a closed result, so subsequent
      assignment is rejected.

Later stages still need coverage for literal typing, including duplicate
labels and punning; get/set on closed and open rows; {..} inference through
filter_method; spread/Oinherit; width subtyping that drops a mutable field;
@obj externals with optional and @as arguments; diagnostics; and the Stage
0 removals.

6. Open questions and implementation validation

6.1 Deferred design questions

  1. Is Fabsent reachable with only Fpresent construction plus Fvar fields
    created by filter_method? ctype.ml:2295 suggests that row closing can
    reach it. The answer determines whether Stage E can simplify field_kind.
  2. Should @obj externals eventually construct object rows directly instead
    of using the labeled-arrow encoding in from_labels? This possible
    convergence with direct literal typing is outside the current scope.

6.2 Required implementation validation

  • Probe current signature inclusion for getter and #= members, then specify
    and test the corresponding Immutable/Mutable flag rules.
  • Validate the proposed constructor names and payloads against all consumers
    before committing the serialized AST shapes.
  • Before Stage E, survey the remaining uses of Tpoly and field_kind and
    retain any state still required by open-row unification.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions