Repository navigation
Keep only the written fields of a record literal in the typed tree - #9149
Conversation
Why:
A record literal stored one entry per field of its type: Kept for a
field copied from a spread, and a synthesized `None` expression for
every optional field it left out. JSX DOM props have 612 optional
fields, so each DOM element added about 610 expressions to the typed
tree, ~31 KB to the .cmt, and as many Lambda constants (#9148). With
800 elements, compiling took 0.80 s and 422 MB; now 0.08 s and 50 MB,
and the .cmt shrinks from 25 MB to 0.8 MB. Small records lose a third
of their .cmt size. The generated JavaScript does not change.
Typedtree modified (record literals):
Example:
type r = {a: int, b?: string}
let x = {a: 1}
Before (typed):
Texp_record
fields =
[
"a"
expression
Texp_constant Const_int 1
"b"
expression
Texp_construct "None"
[]
]
After (typed):
Texp_record
fields =
[
"a"
expression
Texp_constant Const_int 1
]
Analysis modified (find references):
Example:
type labelled = {name: string, label?: string}
let withLabel = {name: "a", label: "b"}
let withoutLabel = {name: "c"}
let _ = withLabel.label
Before (text): references of `label`
26:31 declaration
27:28 withLabel's field
28:25 inside {name: "c"}, which has no label
29:18 withLabel.label
After (text): references of `label`
26:31 declaration
27:28 withLabel's field
29:18 withLabel.label
Review: Where do the left-out fields come from now? Translcore rebuilds
the positional view from lbl_all: a field missing from `fields` is read
from the spread record when there is one, and is `undefined` otherwise.
Review: What else went? The record_label_definition type, the unused
type of Kept fields, and the tuple's optional flag, which was always
false.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kbf3gKyiYMf54wPRxFdUWe
Signed-off-by: Christoph Knittel <ck@cca.io>
Changelog🐛 Bug fix
💅 Polish
Semantic Diff2 commits · Full page Keep only the written fields of a record literal in the typed treeceaaf61 · cknitt + Claude · Oct 31, 2026, 07:01 UTC
Typedtree modified (record literals) type r = {a: int, b?: string}
let x = {a: 1}
Texp_record
fields =
"a"
expression
Texp_constant Const_int 1
- "b"
- expression
- Texp_construct "None"
- []Analysis modified (find references) type labelled = {name: string, label?: string}
let withLabel = {name: "a", label: "b"}
let withoutLabel = {name: "c"}
let _ = withLabel.label
26:31 declaration
27:28 withLabel's field
-28:25 inside {name: "c"}, which has no label
29:18 withLabel.labelReview
✨ Treat omitted mutable fields as allocated in the value restriction9f7babe · cknitt + Claude · Oct 31, 2026, 07:49 UTC · new Codex review: is_nonexpansive now saw only the written fields, so with |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #9149 +/- ##
=======================================
Coverage 81.74% 81.74%
=======================================
Files 460 460
Lines 64949 64940 -9
=======================================
- Hits 53090 53088 -2
+ Misses 11859 11852 -7
🚀 New features to boost your workflow:
|
rescript
@rescript/belt
@rescript/darwin-arm64
@rescript/linux-arm64
@rescript/linux-x64
@rescript/runtime
@rescript/win32-x64
commit: |
|
Developer playground preview: https://rescript-lang.github.io/rescript/dev-playground/?version=pr-9149 |
|
@codex review |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ceaaf61f6a
ℹ️ 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".
Codex review: is_nonexpansive now saw only the written fields, so with
`type cell<'a> = {tag: int, mutable value?: 'a}` the binding
`let cell = {tag: 0}` generalized `'a`, and `cell.value` could be
written as option<int> and read as option<string>. Without a spread,
every label of the record is allocated, so all must be immutable; with
a spread, the check is unchanged. Master rejects the example as well;
the new super_errors fixture covers it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kbf3gKyiYMf54wPRxFdUWe
Signed-off-by: Christoph Knittel <ck@cca.io>
|
@codex review |
|
Codex Review: Didn't find any major issues. More of your lovely PRs please. Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
|
The change is great. |
…escript-lang#9149) * Keep only the written fields of a record literal in the typed tree Why: A record literal stored one entry per field of its type: Kept for a field copied from a spread, and a synthesized `None` expression for every optional field it left out. JSX DOM props have 612 optional fields, so each DOM element added about 610 expressions to the typed tree, ~31 KB to the .cmt, and as many Lambda constants (rescript-lang#9148). With 800 elements, compiling took 0.80 s and 422 MB; now 0.08 s and 50 MB, and the .cmt shrinks from 25 MB to 0.8 MB. Small records lose a third of their .cmt size. The generated JavaScript does not change. Typedtree modified (record literals): Example: type r = {a: int, b?: string} let x = {a: 1} Before (typed): Texp_record fields = [ "a" expression Texp_constant Const_int 1 "b" expression Texp_construct "None" [] ] After (typed): Texp_record fields = [ "a" expression Texp_constant Const_int 1 ] Analysis modified (find references): Example: type labelled = {name: string, label?: string} let withLabel = {name: "a", label: "b"} let withoutLabel = {name: "c"} let _ = withLabel.label Before (text): references of `label` 26:31 declaration 27:28 withLabel's field 28:25 inside {name: "c"}, which has no label 29:18 withLabel.label After (text): references of `label` 26:31 declaration 27:28 withLabel's field 29:18 withLabel.label Review: Where do the left-out fields come from now? Translcore rebuilds the positional view from lbl_all: a field missing from `fields` is read from the spread record when there is one, and is `undefined` otherwise. Review: What else went? The record_label_definition type, the unused type of Kept fields, and the tuple's optional flag, which was always false. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kbf3gKyiYMf54wPRxFdUWe Signed-off-by: Christoph Knittel <ck@cca.io> * Treat omitted mutable fields as allocated in the value restriction Codex review: is_nonexpansive now saw only the written fields, so with `type cell<'a> = {tag: int, mutable value?: 'a}` the binding `let cell = {tag: 0}` generalized `'a`, and `cell.value` could be written as option<int> and read as option<string>. Without a spread, every label of the record is allocated, so all must be immutable; with a spread, the check is unchanged. Master rejects the example as well; the new super_errors fixture covers it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kbf3gKyiYMf54wPRxFdUWe Signed-off-by: Christoph Knittel <ck@cca.io> --------- Signed-off-by: Christoph Knittel <ck@cca.io> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Pedro Castro <aspeddro@gmail.com>
Closes #9148.
A record literal's typed tree used to hold one entry per field of the record type:
Keptfor a field copied from a spread, and a synthesizedNoneexpression for every optional field the literal left out. JSX DOM props (JsxDOM.domProps) have 612 optional fields, so every DOM element added about 610 expressions to the typed tree. Those expressions went into the.cmtand were also translated to Lambda.Now
Texp_record.fieldsholds only the fields the literal writes, ordered bylbl_pos. A field missing from it is copied from the spread record when there is one, and is otherwise an optional field set toNone. The record's full label set is still available throughlbl_all, sotranslcorerebuilds the positional view where it needs one: record blocks, and JSX props copied from a spread.record_label_definition(Kept/Overridden) is gone, as is the tuple's optional flag, which was alwaysfalse.Performance
Measured with a base
bscbuilt from the same master commit, on generated files. Times are user time, minimum of 5 runs; memory is peak RSS. The JavaScript output is byte-identical in every case and across the test suites..cmtbefore → after{...base, a: i}Typing also got cheaper per literal. It used to search the written fields once for each label of the record type, and now it marks written labels in a boolean array indexed by position. The spread path still unifies once for each label of the type.
Editor fix
Each synthesized field carried an identifier located at the record literal, and the editor analysis recorded it as a reference to that label. So "find references" on an optional field listed every literal that omits it. The new case in
tests/analysis_tests/tests/src/References.rescovers this: the baseline analysis returns a fourth reference inside{name: "c"}, and now there are three.🤖 Generated with Claude Code
https://claude.ai/code/session_01Kbf3gKyiYMf54wPRxFdUWe