Repository navigation
Treat a record spread that copies mutable fields as expansive - #9158
Conversation
Changelog🐛 Bug fix
Semantic Diff1 commit · Full page ✏️ Treat a record spread that copies mutable fields as expansivefa279ba · cknitt + Claude · Nov 01, 2026, 07:05 UTC · edited
Diagnostic added (record spread copying a mutable field) type cell<'a> = {tag: int, mutable value: option<'a>}
@val external base: cell<'a> = "base"
let copy = {...base, tag: 1}
copy.value = Some(1)
let s: option<string> = copy.value
-none; `s` holds the number 1 at run time
+This has type: option<int>
+But it's expected to have type: option<string>
+
+The incompatible parts:
+ int vs stringReview
|
|
@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. |
|
Codex Review: Didn't find any major issues. Bravo. 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". |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## master #9158 +/- ##
==========================================
- Coverage 81.74% 81.74% -0.01%
==========================================
Files 460 460
Lines 64944 64942 -2
==========================================
- Hits 53091 53089 -2
Misses 11853 11853
🚀 New features to boost your workflow:
|
rescript
@rescript/belt
@rescript/darwin-arm64
@rescript/linux-arm64
@rescript/linux-x64
@rescript/runtime
@rescript/win32-x64
commit: |
Why:
The value restriction checked only the fields a spread literal writes,
so `{...base, tag: 1}` was non-expansive even when it copied a mutable
field from `base`, and its type was generalized (#9156). A record
literal allocates every field of its type, whether written, copied
from the spread or left out, so the check now requires all of them to
be immutable, with or without a spread.
Diagnostic added (record spread copying a mutable field):
Example:
type cell<'a> = {tag: int, mutable value: option<'a>}
@Val external base: cell<'a> = "base"
let copy = {...base, tag: 1}
copy.value = Some(1)
let s: option<string> = copy.value
Before (diagnostic): none; `s` holds the number 1 at run time
After (diagnostic):
This has type: option<int>
But it's expected to have type: option<string>
The incompatible parts:
int vs string
Review: Does a spread of an immutable record still generalize? Yes; the
super_errors fixture uses one at both option<int> and option<string>
first.
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>
e1dbac7 to
fa279ba
Compare
|
Developer playground preview: https://rescript-lang.github.io/rescript/dev-playground/?version=pr-9158 |
Fixes #9156.
The value restriction (
Typecore.is_nonexpansive) checked only the fields a spread literal writes, and required the spread source to be non-expansive. Fields copied from the source didn't count, so{...base, tag: 1}was generalized even when it copied a mutable field. The literal allocates a fresh record with that field, so the value could be written at one type and read at another:A record literal allocates every field of its type: written, copied from the spread, or left out. The check now treats spread literals like the others (#9149, #9152): all labels must be immutable, the written expressions non-expansive, and the spread source non-expansive. This drops the spread special case from the check.
What newly fails: only a spread that copies a mutable field from a polymorphic, non-expansive source, used at more than one type, or left open at the top level. A literal of a record type with a mutable field is itself expansive, so in practice the source is an external, e.g.
{...defaults, x: 1}with@val external defaults: options<'a>. Spreads of immutable records still generalize. The newsuper_errorsfixturerecord_spread_mutable_does_not_generalize.reschecks both.Changelog: a one-sentence fix entry.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Kbf3gKyiYMf54wPRxFdUWe