Skip to content

Commit beb423d

Browse files
cknittclaude
andauthored
Check field mutability in record coercion (#9086)
* Check field mutability in record coercion Why: `:>` between record types compared field names, optionality and runtime names, but not mutability. A record with an immutable field could be coerced to one where the field is mutable and written through it, although the compiler treats the field as immutable: reads of it were constant-folded to the stale value. A field mutable in both records was also coerced covariantly, so a wider value could be written into a narrower field. Diagnostic modified (coercion that makes a field writable): Example: type ro = {x: float} type rw = {mutable x: float} let toRw = (r: ro) => (r :> rw) Before (js): let r = { x: 0 }; r.x = 1; console.log(0); // r.x, folded to its initial value After (diagnostic): Type ro is not a subtype of rw The record ro cannot be coerced to the record rw because: - The field x is mutable in record rw, but not in record ro. A coercion can't make a field writable. Diagnostic modified (mutable field of a wider type): Example: type narrow = {mutable tag: [#A]} type wide = {mutable tag: [#A | #B]} let widen = (n: narrow) => (n :> wide) Before (diagnostic): accepted After (diagnostic): Type narrow is not a subtype of wide The second polymorphic variant is closed and doesn't include the constructor: #B. Review: Is coercing a mutable field to an immutable one still allowed? Yes. A read-only view is sound: the source is known to be mutable, so its reads are not folded. Breaking: A coercion that makes a field mutable, or that changes the type of a field mutable in both records, is now an error. Declare the field mutable in the source record, or copy the record instead of coercing. Fixes: #9063 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kbf3gLMawVgtmiKy3TMBc3 Signed-off-by: Christoph Knittel <ck@cca.io> * Test the record coercions that stay allowed Codex review on #9086: the fixtures only covered rejected coercions. The new test runs a read-only view of a mutable field, a mutable field kept mutable with shared writes through both views, and an immutable target field of a wider type. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Kbf3gLMawVgtmiKy3TMBc3 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>
1 parent b661068 commit beb423d

9 files changed

Lines changed: 121 additions & 1 deletion
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Record coercion no longer turns an immutable field into a mutable one, and requires a field that is mutable in both records to have the same type in both.

‎compiler/ml/printtyp.ml‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1707,6 +1707,13 @@ let print_record_field_subtype_violation ppf
17071707
label
17081708
(Path.name right_record_name)
17091709
(Path.name left_record_name)
1710+
| Mutable_mismatch {label} ->
1711+
fprintf ppf
1712+
"The field @{<info>%s@} is mutable in record @{<info>%s@}, but not in \
1713+
record @{<info>%s@}. A coercion can't make a field writable."
1714+
label
1715+
(Path.name right_record_name)
1716+
(Path.name left_record_name)
17101717

17111718
let report_subtyping_error ppf env tr1 txt1 tr2 ctx =
17121719
wrap_printing_env env (fun () ->

‎compiler/ml/record_coercion.ml‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ type record_field_subtype_violation =
1010
right_as: string option;
1111
}
1212
| Field_missing of {label: string}
13+
| Mutable_mismatch of {label: string}
1314

1415
let check_record_fields (fields1 : Types.label_declaration list)
1516
(fields2 : Types.label_declaration list) =
@@ -40,7 +41,16 @@ let check_record_fields (fields1 : Types.label_declaration list)
4041
left_as = ld1.ld_runtime_name;
4142
right_as = ld2.ld_runtime_name;
4243
});
43-
(ld1.ld_type :: acc1, ld2.ld_type :: acc2)
44+
(match (ld1.ld_mutable, ld2.ld_mutable) with
45+
| Immutable, Mutable ->
46+
(* writing through the target would change a field that the source
47+
treats as immutable *)
48+
add_violation (Mutable_mismatch {label = ld1.ld_id.name})
49+
| _ -> ());
50+
if ld1.ld_mutable = Mutable && ld2.ld_mutable = Mutable then
51+
(* a field read and written through both types: subtyping both ways *)
52+
(ld1.ld_type :: ld2.ld_type :: acc1, ld2.ld_type :: ld1.ld_type :: acc2)
53+
else (ld1.ld_type :: acc1, ld2.ld_type :: acc2)
4454
| None ->
4555
(* field must be present *)
4656
add_violation (Field_missing {label = ld2.ld_id.name});
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
2+
We've found a bug for you!
3+
/.../fixtures/record_coercion_immutable_to_mutable.res:4:24-30
4+
5+
2 │ type rw = {mutable x: float}
6+
3 │
7+
4 │ let toRw = (r: ro) => (r :> rw)
8+
5 │
9+
10+
Type ro is not a subtype of rw
11+
12+
The record ro cannot be coerced to the record rw because:
13+
- The field x is mutable in record rw, but not in record ro. A coercion can't make a field writable.
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
2+
We've found a bug for you!
3+
/.../fixtures/record_coercion_mutable_field_invariant.res:4:29-37
4+
5+
2 │ type wide = {mutable tag: [#A | #B]}
6+
3 │
7+
4 │ let widen = (n: narrow) => (n :> wide)
8+
5 │
9+
10+
Type narrow is not a subtype of wide
11+
12+
The second polymorphic variant is closed and doesn't include the constructor: #B.
13+
14+
Possible solutions:
15+
- Either make the second variant open so it can accept additional constructors. To do this, make sure the type starts with [> instead of [
16+
- Or add the missing constructor to it.
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
type ro = {x: float}
2+
type rw = {mutable x: float}
3+
4+
let toRw = (r: ro) => (r :> rw)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
type narrow = {mutable tag: [#A]}
2+
type wide = {mutable tag: [#A | #B]}
3+
4+
let widen = (n: narrow) => (n :> wide)
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
// Generated by ReScript, PLEASE EDIT WITH CARE
2+
3+
import * as Mocha from "mocha";
4+
import * as Test_utils from "./test_utils.mjs";
5+
6+
Mocha.describe("Record_coercion_mutable_test", () => {
7+
Mocha.test("read-only view sees later writes", () => {
8+
let c_count = 0;
9+
c_count = c_count + 1 | 0;
10+
Test_utils.eq("File \"record_coercion_mutable_test.res\", line 23, characters 7-14", 1, c_count);
11+
});
12+
Mocha.test("mutable to mutable shares writes", () => {
13+
let b_value = 1;
14+
b_value = 2;
15+
Test_utils.eq("File \"record_coercion_mutable_test.res\", line 30, characters 7-14", 2, b_value);
16+
b_value = 3;
17+
Test_utils.eq("File \"record_coercion_mutable_test.res\", line 32, characters 7-14", 3, b_value);
18+
});
19+
Mocha.test("immutable target field can widen its type", () => {
20+
let n = "A";
21+
Test_utils.eq("File \"record_coercion_mutable_test.res\", line 38, characters 7-14", "A", n);
22+
});
23+
});
24+
25+
/* Not a pure module */
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
open Mocha
2+
open Test_utils
3+
4+
// A mutable field can be viewed through an immutable one: a read-only view.
5+
type counter = {mutable count: int, label: string}
6+
type readOnlyCounter = {count: int}
7+
8+
// A field mutable in both records keeps its type, and both views share the
9+
// same object, so writes through either are seen through the other.
10+
type box = {mutable value: int, extra: string}
11+
type smallBox = {mutable value: int}
12+
13+
// The field type may still be widened when the field is immutable in the
14+
// target, here from a closed polymorphic variant to a wider one.
15+
type narrow = {mutable tag: [#A]}
16+
type wideReadOnly = {tag: [#A | #B]}
17+
18+
describe(__MODULE__, () => {
19+
test("read-only view sees later writes", () => {
20+
let c = {count: 0, label: "c"}
21+
let view = (c :> readOnlyCounter)
22+
c.count = c.count + 1
23+
eq(__LOC__, 1, view.count)
24+
})
25+
26+
test("mutable to mutable shares writes", () => {
27+
let b = {value: 1, extra: "x"}
28+
let small = (b :> smallBox)
29+
small.value = 2
30+
eq(__LOC__, 2, b.value)
31+
b.value = 3
32+
eq(__LOC__, 3, small.value)
33+
})
34+
35+
test("immutable target field can widen its type", () => {
36+
let n: narrow = {tag: #A}
37+
let w = (n :> wideReadOnly)
38+
eq(__LOC__, #A, w.tag)
39+
})
40+
})

0 commit comments

Comments
 (0)