From fa1b7f12bdcf9fef69f16287a39f68261071babd Mon Sep 17 00:00:00 2001 From: mununki Date: Wed, 4 Nov 2026 14:00:54 +0900 Subject: [PATCH] Reject coercions and variant spreads that treat bool and unit as variants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: `bool` and `unit` are declared as variants, with the constructors `false`, `true` and `()` and no `@as`, but their values are `false`, `true` and `undefined`. The coercion between a variant and a primitive left out `bool` but took `unit` for a variant whose constructor is the string "()", so `(() :> string)` compiled to nothing and left `undefined` typed as a `string`. The coercion from a closed polymorphic variant left out neither type and matched `#"true"` to the constructor `true` by name. A variant that spread either type built `true`, `false` and `undefined`, but matched and coerced them as strings. `Variant_coercion.is_bool_or_unit` now keeps both types out of the coercions between a variant and a primitive, the coercion from a polymorphic variant (`type_is_variant`, renamed `can_try_coerce_polyvariant_to_variant`) and variant spreads. `Ctype.subtype_rec` also rejects a coercion between either type and another variant or record before the variant checks, whose messages described them as variants. Diagnostic added (unit coerced to string): Example: let s: string = (() :> string) let n = s->String.length Before (js): throws a TypeError when the module loads let n = undefined.length; let s; After (diagnostic): A.res:1:18-29 Type unit is not a subtype of string Diagnostic added (polymorphic variant coerced to bool): Example: let toBool = (p: [#"true" | #"false"]): bool => (p :> bool) let isFalse = toBool(#"false") == false Before (js): function toBool(p) { return p; } let isFalse = "false" === false; After (diagnostic): A.res:1:50-58 Type [#"false" | #"true"] is not a subtype of bool Diagnostic added (bool spread into a variant): Example: type t = | ...bool | Other let x: t = true let r = switch x { | true => "yes" | false => "no" | Other => "other" } Before (js): `r` stays undefined switch (true) { case "false" : r = "no"; break; case "true" : r = "yes"; break; … } After (diagnostic): A.res:1:15-18 This type is not a valid type to spread. It's only possible to spread other variants. Diagnostic modified (unit coerced to int): Example: let i: int = (() :> int) Before (diagnostic): Type unit is not a subtype of int The constructor () of variant unit has no @as payload, which makes it a string at runtime. That runtime representation is not compatible with the expected runtime representation of int. Fix this by making sure all constructors in variant unit has a runtime representation of int. After (diagnostic): Type unit is not a subtype of int Diagnostic modified (coercion suggested for unit): Example: let u = (): unit => () let s: string = u() Before (diagnostic): This has type: unit But it's expected to have type: string Possible solutions: - These types are compatible at runtime. You can use the coercion operator to convert to the expected type: (u() :> string) After (diagnostic): This has type: unit But it's expected to have type: string Review: Does a working program stop compiling? One that coerces `unit` to `string` or a polymorphic variant to `bool` or `unit`, which gave a value of another type than its own, or that spreads `bool` or `unit` into a variant, whose switches never matched `true`, `false` or `()`. A variant that only used its other constructors can declare them without the spread. Review: Which coercions still work? `bool` and `unit` coerce to themselves, from their aliases and from a private type defined as one, and a variant `False | True` still coerces to `string`. `option`, `list` and `result` have type parameters, so these coercions never applied to them. No existing snapshot or test output changes. Signed-off-by: mununki --- changes/bool-unit-coercion-errors.polish.md | 1 + changes/reject-bool-unit-variant-coercion.fix.md | 2 ++ compiler/ml/ctype.ml | 8 +++++++- compiler/ml/variant_coercion.ml | 15 ++++++++++----- compiler/ml/variant_type_spread.ml | 6 +++++- .../coercion_hint_unit_to_string.res.expected | 10 ++++++++++ .../coercion_polyvariant_to_bool.res.expected | 9 +++++++++ .../coercion_polyvariant_to_unit.res.expected | 9 +++++++++ .../expected/coercion_string_to_unit.res.expected | 8 ++++++++ .../expected/coercion_unit_to_int.res.expected | 8 ++++++++ .../expected/coercion_unit_to_string.res.expected | 8 ++++++++ .../coercion_unit_to_unknown.res.expected | 8 ++++++++ .../expected/variant_spread_bool.res.expected | 10 ++++++++++ .../expected/variant_spread_unit.res.expected | 10 ++++++++++ .../fixtures/coercion_hint_unit_to_string.res | 2 ++ .../fixtures/coercion_polyvariant_to_bool.res | 2 ++ .../fixtures/coercion_polyvariant_to_unit.res | 2 ++ .../fixtures/coercion_string_to_unit.res | 1 + .../fixtures/coercion_unit_to_int.res | 1 + .../fixtures/coercion_unit_to_string.res | 1 + .../fixtures/coercion_unit_to_unknown.res | 1 + .../super_errors/fixtures/variant_spread_bool.res | 3 +++ .../super_errors/fixtures/variant_spread_unit.res | 3 +++ tests/tests/src/VariantCoercion.mjs | 7 +++++++ tests/tests/src/VariantCoercion.res | 12 ++++++++++++ 25 files changed, 140 insertions(+), 7 deletions(-) create mode 100644 changes/bool-unit-coercion-errors.polish.md create mode 100644 changes/reject-bool-unit-variant-coercion.fix.md create mode 100644 tests/build_tests/super_errors/expected/coercion_hint_unit_to_string.res.expected create mode 100644 tests/build_tests/super_errors/expected/coercion_polyvariant_to_bool.res.expected create mode 100644 tests/build_tests/super_errors/expected/coercion_polyvariant_to_unit.res.expected create mode 100644 tests/build_tests/super_errors/expected/coercion_string_to_unit.res.expected create mode 100644 tests/build_tests/super_errors/expected/coercion_unit_to_int.res.expected create mode 100644 tests/build_tests/super_errors/expected/coercion_unit_to_string.res.expected create mode 100644 tests/build_tests/super_errors/expected/coercion_unit_to_unknown.res.expected create mode 100644 tests/build_tests/super_errors/expected/variant_spread_bool.res.expected create mode 100644 tests/build_tests/super_errors/expected/variant_spread_unit.res.expected create mode 100644 tests/build_tests/super_errors/fixtures/coercion_hint_unit_to_string.res create mode 100644 tests/build_tests/super_errors/fixtures/coercion_polyvariant_to_bool.res create mode 100644 tests/build_tests/super_errors/fixtures/coercion_polyvariant_to_unit.res create mode 100644 tests/build_tests/super_errors/fixtures/coercion_string_to_unit.res create mode 100644 tests/build_tests/super_errors/fixtures/coercion_unit_to_int.res create mode 100644 tests/build_tests/super_errors/fixtures/coercion_unit_to_string.res create mode 100644 tests/build_tests/super_errors/fixtures/coercion_unit_to_unknown.res create mode 100644 tests/build_tests/super_errors/fixtures/variant_spread_bool.res create mode 100644 tests/build_tests/super_errors/fixtures/variant_spread_unit.res diff --git a/changes/bool-unit-coercion-errors.polish.md b/changes/bool-unit-coercion-errors.polish.md new file mode 100644 index 00000000000..6611d5d0262 --- /dev/null +++ b/changes/bool-unit-coercion-errors.polish.md @@ -0,0 +1 @@ +- Coercion errors no longer describe `bool` and `unit` as variants that could be given `@as` or `@unboxed`. diff --git a/changes/reject-bool-unit-variant-coercion.fix.md b/changes/reject-bool-unit-variant-coercion.fix.md new file mode 100644 index 00000000000..368a2b0a50a --- /dev/null +++ b/changes/reject-bool-unit-variant-coercion.fix.md @@ -0,0 +1,2 @@ +- Reject coercing `unit` to `string`, as in `(() :> string)`, and a closed polymorphic variant to `bool` or `unit`, as from `[#"true" | #"false"]` to `bool`. These compiled, but left `undefined` typed as a `string`, or a string typed as a `bool` or `unit`, and type errors suggested them as fixes. +- Reject spreading `bool` or `unit` into a variant, as in `type t = | ...bool | Other`. The variant held `true`, `false` and `undefined`, but its switches and coercions took them for the strings `"true"`, `"false"` and `"()"`. diff --git a/compiler/ml/ctype.ml b/compiler/ml/ctype.ml index 4116d748fe1..ed1f1b21220 100644 --- a/compiler/ml/ctype.ml +++ b/compiler/ml/ctype.ml @@ -3698,6 +3698,12 @@ let rec subtype_rec env trace t1 t2 cstrs = match (extract_concrete_typedecl env t1, extract_concrete_typedecl env t2) with + | (_, p1, _), (_, p2, _) + when Variant_coercion.is_bool_or_unit p1 + || Variant_coercion.is_bool_or_unit p2 -> + (* No other type coerces to or from [bool] or [unit], and the + variant errors below would describe them as variants *) + (trace, t1, t2, !(current_univar_pairs ()), None) :: cstrs | ( ( p1, _, { @@ -3918,7 +3924,7 @@ let rec subtype_rec env trace t1 t2 cstrs = (trace, t1, t2, !(current_univar_pairs ()), None) :: cstrs) | Tvariant {row_closed = true; row_fields}, Tconstr (_, [], _) when extract_concrete_typedecl_opt env t2 - |> Variant_coercion.type_is_variant -> ( + |> Variant_coercion.can_try_coerce_polyvariant_to_variant -> ( (* TODO(subtype-errors) Polyvariant to variant *) match extract_concrete_typedecl env t2 with | ( _, diff --git a/compiler/ml/variant_coercion.ml b/compiler/ml/variant_coercion.ml index 11e90a6e71a..314fa791dc6 100644 --- a/compiler/ml/variant_coercion.ml +++ b/compiler/ml/variant_coercion.ml @@ -19,6 +19,12 @@ type variant_runtime_representation_issue = expected_typename: Path.t; } +(* [bool] and [unit] are declared as variants, but their values are [true], + [false] and [undefined] at runtime, not what the constructors of a declared + variant compile to. No variant coercion or spread applies to them. *) +let is_bool_or_unit (path : Path.t) = + Path.same path Predef.path_bool || Path.same path Predef.path_unit + (* Right now we only allow coercing to primitives string/int/float *) let can_coerce_primitive (path : Path.t) = Path.same path Predef.path_string @@ -122,9 +128,7 @@ let can_try_coerce_variant_to_primitive type_params = []; type_representation; } - when not (Path.same p Predef.path_bool) -> - (* bool is represented as a variant internally, so we need to account for that *) - (* TODO(subtype-errors) Report about bool? *) + when not (is_bool_or_unit p) -> let layout = Variant_runtime.get_layout layout_ref in Some ( p, @@ -268,7 +272,8 @@ let can_coerce_polyvariant_to_variant ~row_fields ~variant_constructors ~unboxed then Ok () else Error `Unknown -let type_is_variant (typ : (Path.t * Path.t * Types.type_declaration) option) = +let can_try_coerce_polyvariant_to_variant + (typ : (Path.t * Path.t * Types.type_declaration) option) = match typ with - | Some (_, _, {type_kind = Type_variant _; _}) -> true + | Some (_, p, {type_kind = Type_variant _}) -> not (is_bool_or_unit p) | _ -> false diff --git a/compiler/ml/variant_type_spread.ml b/compiler/ml/variant_type_spread.ml index a1524ac7958..446f5c4f47f 100644 --- a/compiler/ml/variant_type_spread.ml +++ b/compiler/ml/variant_type_spread.ml @@ -36,7 +36,7 @@ let map_constructors ~(sdecl : Parsetree.type_declaration) ~all_constructors env pcd_args = Pcstr_tuple [{ptyp_loc; ptyp_desc = Ptyp_constr (loc, _)}]; } -> ( (* This is a variant type spread constructor. Look up its type *) - let _, type_decl = + let path, type_decl = try Typetexp.find_type env ptyp_loc loc.txt with _ -> raise (VariantTypeSpreadError (loc.loc, CouldNotFindType)) in @@ -44,6 +44,10 @@ let map_constructors ~(sdecl : Parsetree.type_declaration) ~all_constructors env match type_decl with | {type_kind = Type_variant ([], _)} -> raise (VariantTypeSpreadError (loc.loc, InvalidType)) + | {type_kind = Type_variant _} when Variant_coercion.is_bool_or_unit path -> + (* Their constructors would build [true], [false] and [undefined], but + would be matched and coerced as the strings of their names *) + raise (VariantTypeSpreadError (loc.loc, InvalidType)) | {type_kind = Type_variant (cstrs, _); type_attributes; type_params} -> if List.length type_params > 0 then raise (VariantTypeSpreadError (loc.loc, HasTypeParams)); diff --git a/tests/build_tests/super_errors/expected/coercion_hint_unit_to_string.res.expected b/tests/build_tests/super_errors/expected/coercion_hint_unit_to_string.res.expected new file mode 100644 index 00000000000..6532e1e9c28 --- /dev/null +++ b/tests/build_tests/super_errors/expected/coercion_hint_unit_to_string.res.expected @@ -0,0 +1,10 @@ + + We've found a bug for you! + /.../fixtures/coercion_hint_unit_to_string.res:2:17-19 + + 1 │ let u = (): unit => () + 2 │ let s: string = u() + 3 │ + + This has type: unit + But it's expected to have type: string \ No newline at end of file diff --git a/tests/build_tests/super_errors/expected/coercion_polyvariant_to_bool.res.expected b/tests/build_tests/super_errors/expected/coercion_polyvariant_to_bool.res.expected new file mode 100644 index 00000000000..1be2d51ecdb --- /dev/null +++ b/tests/build_tests/super_errors/expected/coercion_polyvariant_to_bool.res.expected @@ -0,0 +1,9 @@ + + We've found a bug for you! + /.../fixtures/coercion_polyvariant_to_bool.res:2:16-24 + + 1 │ let p: [#"true"] = #"true" + 2 │ let b: bool = (p :> bool) + 3 │ + + Type [#"true"] is not a subtype of bool \ No newline at end of file diff --git a/tests/build_tests/super_errors/expected/coercion_polyvariant_to_unit.res.expected b/tests/build_tests/super_errors/expected/coercion_polyvariant_to_unit.res.expected new file mode 100644 index 00000000000..a36cc8b60aa --- /dev/null +++ b/tests/build_tests/super_errors/expected/coercion_polyvariant_to_unit.res.expected @@ -0,0 +1,9 @@ + + We've found a bug for you! + /.../fixtures/coercion_polyvariant_to_unit.res:2:16-24 + + 1 │ let p: [#"()"] = #"()" + 2 │ let u: unit = (p :> unit) + 3 │ + + Type [#"()"] is not a subtype of unit \ No newline at end of file diff --git a/tests/build_tests/super_errors/expected/coercion_string_to_unit.res.expected b/tests/build_tests/super_errors/expected/coercion_string_to_unit.res.expected new file mode 100644 index 00000000000..e83f5c56242 --- /dev/null +++ b/tests/build_tests/super_errors/expected/coercion_string_to_unit.res.expected @@ -0,0 +1,8 @@ + + We've found a bug for you! + /.../fixtures/coercion_string_to_unit.res:1:16-26 + + 1 │ let u: unit = ("a" :> unit) + 2 │ + + Type string is not a subtype of unit \ No newline at end of file diff --git a/tests/build_tests/super_errors/expected/coercion_unit_to_int.res.expected b/tests/build_tests/super_errors/expected/coercion_unit_to_int.res.expected new file mode 100644 index 00000000000..9be7b11fe04 --- /dev/null +++ b/tests/build_tests/super_errors/expected/coercion_unit_to_int.res.expected @@ -0,0 +1,8 @@ + + We've found a bug for you! + /.../fixtures/coercion_unit_to_int.res:1:15-23 + + 1 │ let i: int = (() :> int) + 2 │ + + Type unit is not a subtype of int \ No newline at end of file diff --git a/tests/build_tests/super_errors/expected/coercion_unit_to_string.res.expected b/tests/build_tests/super_errors/expected/coercion_unit_to_string.res.expected new file mode 100644 index 00000000000..f7ba693108b --- /dev/null +++ b/tests/build_tests/super_errors/expected/coercion_unit_to_string.res.expected @@ -0,0 +1,8 @@ + + We've found a bug for you! + /.../fixtures/coercion_unit_to_string.res:1:18-29 + + 1 │ let s: string = (() :> string) + 2 │ + + Type unit is not a subtype of string \ No newline at end of file diff --git a/tests/build_tests/super_errors/expected/coercion_unit_to_unknown.res.expected b/tests/build_tests/super_errors/expected/coercion_unit_to_unknown.res.expected new file mode 100644 index 00000000000..f735acd0fd9 --- /dev/null +++ b/tests/build_tests/super_errors/expected/coercion_unit_to_unknown.res.expected @@ -0,0 +1,8 @@ + + We've found a bug for you! + /.../fixtures/coercion_unit_to_unknown.res:1:19-31 + + 1 │ let u: unknown = (() :> unknown) + 2 │ + + Type unit is not a subtype of unknown \ No newline at end of file diff --git a/tests/build_tests/super_errors/expected/variant_spread_bool.res.expected b/tests/build_tests/super_errors/expected/variant_spread_bool.res.expected new file mode 100644 index 00000000000..4b9273360a8 --- /dev/null +++ b/tests/build_tests/super_errors/expected/variant_spread_bool.res.expected @@ -0,0 +1,10 @@ + + We've found a bug for you! + /.../fixtures/variant_spread_bool.res:2:8-11 + + 1 │ type t = + 2 │ | ...bool + 3 │ | Other + 4 │ + + This type is not a valid type to spread. It's only possible to spread other variants. \ No newline at end of file diff --git a/tests/build_tests/super_errors/expected/variant_spread_unit.res.expected b/tests/build_tests/super_errors/expected/variant_spread_unit.res.expected new file mode 100644 index 00000000000..f6b1fa51230 --- /dev/null +++ b/tests/build_tests/super_errors/expected/variant_spread_unit.res.expected @@ -0,0 +1,10 @@ + + We've found a bug for you! + /.../fixtures/variant_spread_unit.res:2:8-11 + + 1 │ type t = + 2 │ | ...unit + 3 │ | Other + 4 │ + + This type is not a valid type to spread. It's only possible to spread other variants. \ No newline at end of file diff --git a/tests/build_tests/super_errors/fixtures/coercion_hint_unit_to_string.res b/tests/build_tests/super_errors/fixtures/coercion_hint_unit_to_string.res new file mode 100644 index 00000000000..a94231fa8d4 --- /dev/null +++ b/tests/build_tests/super_errors/fixtures/coercion_hint_unit_to_string.res @@ -0,0 +1,2 @@ +let u = (): unit => () +let s: string = u() diff --git a/tests/build_tests/super_errors/fixtures/coercion_polyvariant_to_bool.res b/tests/build_tests/super_errors/fixtures/coercion_polyvariant_to_bool.res new file mode 100644 index 00000000000..a5ac8661da5 --- /dev/null +++ b/tests/build_tests/super_errors/fixtures/coercion_polyvariant_to_bool.res @@ -0,0 +1,2 @@ +let p: [#"true"] = #"true" +let b: bool = (p :> bool) diff --git a/tests/build_tests/super_errors/fixtures/coercion_polyvariant_to_unit.res b/tests/build_tests/super_errors/fixtures/coercion_polyvariant_to_unit.res new file mode 100644 index 00000000000..40d0be715c0 --- /dev/null +++ b/tests/build_tests/super_errors/fixtures/coercion_polyvariant_to_unit.res @@ -0,0 +1,2 @@ +let p: [#"()"] = #"()" +let u: unit = (p :> unit) diff --git a/tests/build_tests/super_errors/fixtures/coercion_string_to_unit.res b/tests/build_tests/super_errors/fixtures/coercion_string_to_unit.res new file mode 100644 index 00000000000..590a4d3730b --- /dev/null +++ b/tests/build_tests/super_errors/fixtures/coercion_string_to_unit.res @@ -0,0 +1 @@ +let u: unit = ("a" :> unit) diff --git a/tests/build_tests/super_errors/fixtures/coercion_unit_to_int.res b/tests/build_tests/super_errors/fixtures/coercion_unit_to_int.res new file mode 100644 index 00000000000..c863192dfd9 --- /dev/null +++ b/tests/build_tests/super_errors/fixtures/coercion_unit_to_int.res @@ -0,0 +1 @@ +let i: int = (() :> int) diff --git a/tests/build_tests/super_errors/fixtures/coercion_unit_to_string.res b/tests/build_tests/super_errors/fixtures/coercion_unit_to_string.res new file mode 100644 index 00000000000..6d59bcdb66b --- /dev/null +++ b/tests/build_tests/super_errors/fixtures/coercion_unit_to_string.res @@ -0,0 +1 @@ +let s: string = (() :> string) diff --git a/tests/build_tests/super_errors/fixtures/coercion_unit_to_unknown.res b/tests/build_tests/super_errors/fixtures/coercion_unit_to_unknown.res new file mode 100644 index 00000000000..c1238810c79 --- /dev/null +++ b/tests/build_tests/super_errors/fixtures/coercion_unit_to_unknown.res @@ -0,0 +1 @@ +let u: unknown = (() :> unknown) diff --git a/tests/build_tests/super_errors/fixtures/variant_spread_bool.res b/tests/build_tests/super_errors/fixtures/variant_spread_bool.res new file mode 100644 index 00000000000..6ffd2bb8ab9 --- /dev/null +++ b/tests/build_tests/super_errors/fixtures/variant_spread_bool.res @@ -0,0 +1,3 @@ +type t = + | ...bool + | Other diff --git a/tests/build_tests/super_errors/fixtures/variant_spread_unit.res b/tests/build_tests/super_errors/fixtures/variant_spread_unit.res new file mode 100644 index 00000000000..3e1dfc5480e --- /dev/null +++ b/tests/build_tests/super_errors/fixtures/variant_spread_unit.res @@ -0,0 +1,3 @@ +type t = + | ...unit + | Other diff --git a/tests/tests/src/VariantCoercion.mjs b/tests/tests/src/VariantCoercion.mjs index 0459f6047e4..5d298c9451c 100644 --- a/tests/tests/src/VariantCoercion.mjs +++ b/tests/tests/src/VariantCoercion.mjs @@ -104,6 +104,12 @@ let CoerceVariantBinaryOp = { z: z }; +let CoerceBoolLikeVariant = { + t: "True", + s: "True", + fromPoly: "True" +}; + let PrivateColor = { red: "Red" }; @@ -145,6 +151,7 @@ export { CoerceFromBigintToVariant, CoerceFromPolyvariantToVariant, CoerceVariantBinaryOp, + CoerceBoolLikeVariant, PrivateColor, colorToPlain, colorToString, diff --git a/tests/tests/src/VariantCoercion.res b/tests/tests/src/VariantCoercion.res index 95467ed5e61..c00ffad29a7 100644 --- a/tests/tests/src/VariantCoercion.res +++ b/tests/tests/src/VariantCoercion.res @@ -153,6 +153,18 @@ module CoerceVariantBinaryOp = { let z = (X :> float) +. (Y :> float) +. 1.5 } +// [bool] and [unit] coerce to no primitive, since they are not variants at +// runtime, but a declared variant whose constructors are named like them does +module CoerceBoolLikeVariant = { + type t = False | True + + let t: t = True + let s = (t :> string) + + type polyTrue = [#True] + let fromPoly = ((#True: polyTrue) :> t) +} + // A private variant coerces out of itself, but nothing coerces into it from // another type module PrivateColor: {