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
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:
- Object literals:
{"a": 1}
- 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).
- Property access:
obj["x"] and assignment obj["x"] = v
- Width subtyping via coercion:
(o :> {"y": string})
@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:
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)
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
- 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.
- 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.
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:
"x#="row member) permits unrelated getter and setter types on one property, which is a demonstrated type-preservation failure (§2.7: a binding declaredintevaluates to"hello"at runtime, from honest declarations). The proposed representation makes that state inexpressible.letmodule-wrapped external and become first-class nodes;Lsend/Pjs_unsafe_downgradedisappear, converging Lambda and Lam further.Stages
@set({no_get}),@get(null|undefined|nullable))Tobjectmemo,Texp_sendvestiges, frontend##)Lsend→ sharedPjs_object_get/Pjs_object_set)#=everywhere)Tpolywrapping,field_kindlattice)Objects, from first principles: end-to-end representation cleanup
Design document (also tracked in the implementation branch as
docs/object_representation_cleanup.md). This documentguides 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:
{"a": 1}{"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).obj["x"]and assignmentobj["x"] = v(o :> {"y": string})@objexternals (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_objecthave already been removed, but substantialsupporting 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). Theprinter resugars it back (res_printer.ml:4302, 4587).
{"a": 1}→Pexp_extension(%obj, record with quoted labels)(res_core.ml:3544).
Ptyp_objectwithOtag/Oinheritfields; spread...tbecomes
Oinherit(res_core.ml:5903).2.2 Frontend (
compiler/frontend/)ast_exp_apply.ml:170-198): the#=application isrewritten to a method call on a phantom method with a mangled name:
obj # "x#=" (v)constrained tounit."#="isLiterals.setter_suffix.ast_core_type_class_type.ml:54): to make the abovetypecheck, an object-type field
@set "x": intis expanded into two rowmembers:
x: intandx#=: int => unit. Mutability is thus encoded asthe existence of a second member with a mangled name. The same machinery
(
ast_attributes.ml:process_method_attributes_rev) also implements theobsolete 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 inJs.null/undefined/nullable. Both are removed, not redesigned (§4Stage 0).
ast_util.ml:record_as_js_object,via
ast_exp_extension.ml:66):%objis expanded into a synthetic localexternal 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 arrowtype, not directly as an object type, and reaches the backend through
Ffi_obj_create.##remnant (ast_exp_apply.ml:135): the##operator is handled herebut 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
Tobjectrows built fromTfield/Tnil, open-row unification, and width subtyping inctype.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 (
#cnaming). No reachable path can construct the abbreviation state: every
creation site builds
ref None, and the surviving writers only propagate orclear an existing
Some(set_nameat ctype.ml:2280 copies!nm1;ctype.ml:3299-3301 rebuilds from a
contents = Somematched upstream at3282; normalize at ctype.ml:4059-4074 rewrites or clears an existing
Some). The creators lived in the removed class-abbreviation machinery, soby induction the ref is always
None; the readers (printtyp.ml:694, thesubtype walk, ctype.ml:592, 1308) and propagating writers are dead with it.
field_kind = Fvar | Fpresent | Fabsent(types.ml:62): designed for OCamlmethod hiding. User syntax only constructs
Fpresent(typetexp.ml:625), butthe lattice is still exercised internally: typing
obj["x"]against{..}creates an
Fvarfield (filter_method), and row-closing changes it toFabsent(ctype.ml:2295). These states implement field-presence negotiationfor open rows and are handled at approximately 26 unification, copying, and
substitution sites.
Tpolywrapping: every object field type is wrapped inTpoly(ty, [])(
typetexp.ml:transl_fieldsviatransl_poly_type) andPexp_sendtypingunwraps it, although the parser does not produce
Ptyp_polyfor objectfields and therefore cannot declare polymorphic object methods.
Texp_send of expression * meth * expression option(typedtree.ml:126): thethird field is always
None— typecore.ml:3303-3309 is a degeneratematch obj.exp_desc with | _ ->producing it, next to an unusedobj_meths = ref None.meth = Tmeth_name of stringis asingle-constructor wrapper; OCaml's other constructors were removed with
classes.
#=members:filter_methodfinds themangled member in the row. The encoding therefore affects unification,
signature inclusion, and serialized cmis.
Type t = {"x": int, "x#=": int => unit} is not a subtype of …. Theoutcome printer (res_outcome_printer.ml:342
print_object_fields) does notresugar it to
@set.gentype/runtime.ml:38check_mutable_object_fieldchecks the#=suffix to reconstructmutability.
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.
Texp_apply { funct = Texp_send(…, "x#=") }and translateto
Lapply { ap_func = Lsend … }; the name suffix is the only indicationthat the operation is a setter at this point.
2.5 Lambda → Lam convert (
compiler/core/lam_convert.ml:336-351)Lapply { ap_func = Lsend(name, …) }by the"#="suffix, stripsit with
String.sub, and producesPjs_unsafe_downgrade {name; setter = true}; bareLsendbecomessetter = false. This is a remaining string-based encoding and a differencebetween Lambda and Lam:
Lsendexists only in Lambda, whilePjs_unsafe_downgradeexists only in Lam.obj##x, which was typed viaJs.unsafe_downgrade : Js.t<'a> => 'a. "Downgrade" referred to unwrappingthe since-removed
Js.twrapper, and "unsafe" referred to bypassing OCamlmethod dispatch. The primitive now represents plain property access.
2.6 Backend (
compiler/core/lam_compile.ml:1769-1815)E.dot obj property; setter emitsE.assign (E.dot obj property) value; unit. Correctly classified aseffectful (lam_analysis.ml:96 — property accessors can run code).
Pjs_object_create(viaFfi_obj_createfromthe 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
bscand executed under Node:The emitted JavaScript is
o.x = "hello"; return o.x;. At runtime,resultis the string
"hello"even though its declared type isint. Assignmentadds 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 getterwideand setternarrowon 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"] : stringalso compiles) produces awrong 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. PureReScript 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: witho: {"x": int, "x#=": string => unit}, the assignmento["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
TobjectbecomesTobject of type_expr; the dead class-abbreviation field isremoved. Each row field has a two-value mutability flag:
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
@setfield in an object type isMutable. A field without@setisImmutable; this includes inferred object-literal fields and fields createdby getter lookup on an open row. Getter and setter operations on a
Mutablefield always use the same
typ. The inconsistent getter/setter state describedin §2.7 is intentionally not representable.
The
Immutable/Mutablenames match the established record-fieldterminology. 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
Tpolywrapper from field types and reducingfield_kindarepossible follow-up simplifications rather than requirements of this stage
(§4 Stage E and §6).
3.2 Promotion, assignment, and inference
Promotion changes
Immutable AtoMutable A. It preserves the field type: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
Immutablefield 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
stringto an open field already known asintfails instead of producing a getter ofintand setter ofstring.let readX = obj => obj["x"]continues to infer an open object row. The rowvariable 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 typingthe 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
AandBas field types:Immutable AImmutable BA <: BMutable AImmutable BA <: B; the target forgets write permissionImmutable AMutable BMutable AMutable BA <: BandB <: A; mutable fields are invariantThese 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}:Immutable AtoMutable A; then requireAandBto be equivalent. Thus({.. "x": wide} :> {@set "x": wide})remains valid, while({.. "x": wide} :> {@set "x": narrow})becomes an error.The result is read-only, so later promotion of the source cannot introduce
writes through the result.
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 bythe equality branch at ctype.ml:3760 ("Same row variable implies same
object").
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:
The
sendterminology is removed because these nodes represent JavaScriptproperty operations, not method dispatch. The
Pjs_prefix matchesPjs_object_createand identifies JavaScript-specific operations.3.5 Object creation
Object literals and
@objexternals share a semantic creation payload ratherthan exposing
External_arg_specin the IR:Alwayscovers literal fields and ordinary@objarguments.If_presentomits an optional
@objproperty when the ReScript optional argument isabsent.
Pjs_object_createreceives one value, already converted to its JavaScriptrepresentation, per property. Translation sequences effects from ignored or
unit arguments outside the primitive, materializes
@asconstants as values,performs FFI conversions before creation, and represents optional presence
separately from the converted value. In particular,
If_presentdepends onwhether the ReScript optional is present, not on whether the converted value
is
undefined; nested options make that distinction observable. Allobj_arg_typevariants reachable through@obj(including@string/@intpolyvar 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%objextension,record_as_js_object, and syntheticlet-module/external expansion are removed.
Ffi_obj_createremains for user@objexternals.3.6 Frozen parsetree bridge
parsetree0.mlremains unchanged. The v0 bridge must round-trip the new formslosslessly using the existing encodings:
Pexp_object_literal%objextension over a recordPexp_object_getPexp_sendPexp_object_set#=application overPexp_sendGeneric PPX traversal therefore continues to work, and
ast_mapper_from0reconstructs the new nodes before frontend processing.
3.7 Result by compiler layer
@setsets the mutability flag. Stage 0 removes the twoobsolete object-field attribute forms.
%obj,record_as_js_object, andfrontend
##handling are removed.promotion rules above. No phantom setter member is created.
Texp_object_getandTexp_object_settranslate directlyto
Pjs_object_getandPjs_object_set, which are identical in Lambda andLam.
Lsend, its conversion cases, suffix recognition, andPjs_unsafe_downgradeare removed.obj.x,obj.x = value, and objectliterals. 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-typefields become errors with a clear message (bare
@setstays). Delete theirprocessing in
ast_attributes.ml/ast_core_type_class_type.ml; update orremove the affected fixtures (
mutable_obj_test.resforno_get). Anullable getter type is written explicitly as
null<int>,undefined<int>, ornullable<int>.design against the trimmed surface only.
Stage A — mechanical deletions
Tobject. No creator of itsSomestateremains (§2.3), so its readers and propagation-only writers are also
removed.
Texp_sendthird field → delete;Tmeth_namewrapper → inline asstring; remove the degenerate match and unusedobj_methsstate intypecore.
##handling (ast_exp_apply.ml:135): unreachablefrom the parser; no PPX is believed to produce it, and the v0 bridge in
§3.6 carries the supported encodings regardless.
Stage B — Lambda/Lam convergence for property access
lam_converttotranslcore: translationpattern-matches
Texp_apply {funct = Texp_send(…, name)}with the suffixand emits the shared primitive; bare sends emit the getter primitive.
Pjs_object_get/Pjs_object_setidentically inlambda.mlandlam_primitive.ml; deleteLsendfrom Lambda and the two convert cases;delete
Pjs_unsafe_downgrade.#=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.
Stage C — direct object literals
{"a": 1}directly: the parser producesPexp_object_literal;typecore types it as a closed object row (
Texp_object_literal); translcoreemits
Pjs_object_create.%objexpansion,record_as_js_object, and the letmodule/externalsynthesis. Note literals have no optional/
@asmachinery to preserve —pval_prim_of_labelsbuilds plain labels only (obj_arg_type = Nothing);optionals/
@asbelong to@objexternals and stay there.source-order, exactly-once evaluation of field expressions; punning;
duplicate-name behavior and its diagnostic; and inferred literal fields as
Immutable. Use the semanticPjs_object_createpayload from §3.5; do notexpose
External_arg_spec.obj_paramsin parsetree or typedtree nodes.Pexp_object_literal↔ the reserved%objextensionshape (§3.6).
ast_mapper/ast_iterator(and the v0mappers),
res_printer,res_comments_table,printast/pprintast,depend, and analysis — completion special-cases%objtoday(
analysis/src/completion_front_end.ml:1233) and must handle the literalnode instead. Add analysis/syntax tests alongside.
Stage D — explicit object-field mutability
object row fields; type
obj["x"] = vagainst them; stop generatingphantom members in
ast_core_type_class_type.Pexp_object_get/Pexp_object_setandTexp_object_get/Texp_object_setreplacePexp_send+ the#=-applyencoding (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.signature inclusion, subtyping, and type printing according to §§3.2–3.3.
printtypandres_outcome_printerreconstruct@setdirectly from theflag.
check_mutable_object_fieldsuffix check and read the flaginstead. Diagnostics no longer print phantom
"x#="members.tests listed in §5.3.
Stage E — optional simplifications (after D)
Tpolywrapping of field types (survey otherTpolyuses first;send-typing unwrap disappears).
field_kindlattice to what{..}negotiation needs. This is aunification-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:
Tobjectshape) + cmt bump (Texp_sendshape).Lam.tis 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 areper-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.
Tfieldshape)5.2 Compatibility boundaries
believed to interpret these object encodings semantically; generic traversal
continues to see the existing v0 shapes.
#=suffix check with the mutabilityflag.
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.rescovers 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.
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 tomutable target.
object_coercion_mutable_unequal.res: mutable-to-mutable invariance.object_open_write_readonly_caller.res: assignment-driven strengtheningrejects a read-only caller.
object_coercion_promote_readonly_caller.res: coercion-drivenstrengthening rejects a read-only caller.
object_write_after_open_target_coercion.res: coercion from a closedsource 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 throughfilter_method; spread/Oinherit; width subtyping that drops a mutable field;@objexternals with optional and@asarguments; diagnostics; and the Stage0 removals.
6. Open questions and implementation validation
6.1 Deferred design questions
Fabsentreachable with onlyFpresentconstruction plusFvarfieldscreated by
filter_method?ctype.ml:2295suggests that row closing canreach it. The answer determines whether Stage E can simplify
field_kind.@objexternals eventually construct object rows directly insteadof using the labeled-arrow encoding in
from_labels? This possibleconvergence with direct literal typing is outside the current scope.
6.2 Required implementation validation
#=members, then specifyand test the corresponding
Immutable/Mutableflag rules.before committing the serialized AST shapes.
Tpolyandfield_kindandretain any state still required by open-row unification.