Semantic Diff

#9218 Reject coercions and variant spreads that treat bool and unit as variants

1 commit

227b697763ca1c08773343bd9e4da9629b920037..pr-9218

fa1b7f12b · mununki · Nov 04, 2026, 05:00 UTC

Reject coercions and variant spreads that treat bool and unit as variants

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.

addedDiagnostic unit coerced to string

let s: string = (() :> string)let n = s->String.length
js
Before throws a TypeError when the module loads
let n = undefined.length; let s;
diagnostic
After
A.res:1:18-29Type unit is not a subtype of string

addedDiagnostic polymorphic variant coerced to bool

let toBool = (p: [#"true" | #"false"]): bool => (p :> bool)let isFalse = toBool(#"false") == false
js
Before
function toBool(p) {  return p;} let isFalse = "false" === false;
diagnostic
After
A.res:1:50-58Type [#"false" | #"true"] is not a subtype of bool

addedDiagnostic bool spread into a variant

type t = | ...bool | Otherlet x: t = truelet r = switch x { | true => "yes" | false => "no" | Other => "other" }
js
Before r stays undefined
switch (true) {  case "false" :    r = "no";    break;  case "true" :    r = "yes";    break;  …}
diagnostic
After
A.res:1:15-18This type is not a valid type to spread. It's only possible to spread other variants.

modifiedDiagnostic unit coerced to int

let i: int = (() :> int)
diagnostic
Before
After
Type unit is not a subtype of intType 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.

modifiedDiagnostic coercion suggested for unit

let u = (): unit => ()let s: string = u()
diagnostic
Before
After
This has type: unitThis has type: unitBut it's expected to have type: stringBut 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)

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.
  • 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.