Repository: rescript-lang/rescript
Pull request #7505: Hint about coercion in error msg
Author: login: zth
Created: 2025-05-22T18:38:55Z | Merged: 2025-05-23T11:46:23Z

This is something I've been wanting to do for a long time - hint in error messages when types are subtypes, and because of that can be coerced to the appropriate type.

I think this is going to improve the discoverability of coercion a ton, by that improve its usability.

@tsnobip @cknitt a round of message feedback please.


Diff:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b44e383e830..7811976ae2c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -26,6 +26,7 @@
 - Complete from `RegExp` stdlib module for regexes. https://github.com/rescript-lang/rescript/pull/7425
 - Allow oneliner formatting when including module with single type alias. https://github.com/rescript-lang/rescript/pull/7502
 - Improve error messages for JSX type mismatches, passing objects where record is expected, passing array literal where tuple is expected, and more. https://github.com/rescript-lang/rescript/pull/7500
+- Show in error messages when coercion can be used to fix a type mismatch. https://github.com/rescript-lang/rescript/pull/7505
 
 # 12.0.0-alpha.13
 
diff --git a/compiler/ml/error_message_utils.ml b/compiler/ml/error_message_utils.ml
index ebc138284fe..267ac6c0f6a 100644
--- a/compiler/ml/error_message_utils.ml
+++ b/compiler/ml/error_message_utils.ml
@@ -11,6 +11,8 @@ let with_configured_jsx_module s =
 module Parser : sig
   type comment
 
+  val extract_text_at_loc : Location.t -> string
+
   val parse_source : (string -> Parsetree.structure * comment list) ref
 
   val reprint_source : (Parsetree.structure -> comment list -> string) ref
@@ -38,10 +40,13 @@ end = struct
     let end_offset = end_pos.pos_cnum in
     String.sub src start_offset (end_offset - start_offset)
 
-  let parse_expr_at_loc loc =
+  let extract_text_at_loc loc =
     (* TODO: Maybe cache later on *)
     let src = Ext_io.load_file loc.Location.loc_start.pos_fname in
-    let sub_src = extract_location_string ~src loc in
+    extract_location_string ~src loc
+
+  let parse_expr_at_loc loc =
+    let sub_src = extract_text_at_loc loc in
     let parsed, comments = !parse_source sub_src in
     match parsed with
     | [{Parsetree.pstr_desc = Pstr_eval (exp, _)}] -> Some (exp, comments)
@@ -59,6 +64,11 @@ end = struct
     | None -> None
 end
 
+let type_expr ppf typ =
+  (* print a type and avoid infinite loops *)
+  Printtyp.reset_and_mark_loops typ;
+  Printtyp.type_expr ppf typ
+
 type type_clash_statement = FunctionCall
 type type_clash_context =
   | SetRecordField
@@ -345,6 +355,43 @@ let print_extra_type_clash_help ~extract_concrete_typedecl ~env loc ppf
          single JSX element.@,"
         (with_configured_jsx_module "array")
     | _ -> ())
+  | _, Some (t1, t2) ->
+    let is_subtype =
+      try
+        Ctype.subtype env t1 t2 ();
+        true
+      with _ -> false
+    in
+    let target_type_string = Format.asprintf "%a" type_expr t2 in
+    let target_expr_text = Parser.extract_text_at_loc loc in
+    let suggested_rewrite =
+      match
+        !Parser.parse_source
+          (Printf.sprintf "(%s :> %s)" target_expr_text target_type_string)
+      with
+      | [], _ -> None
+      | structure, comments -> Some (!Parser.reprint_source structure comments)
+    in
+
+    (* Suggesting coercion only makes sense for non-constant values. *)
+    let is_constant =
+      match !Parser.parse_source target_expr_text with
+      | ( [{Parsetree.pstr_desc = Pstr_eval ({pexp_desc = Pexp_constant _}, _)}],
+          _ ) ->
+        true
+      | _ -> false
+    in
+
+    if is_subtype && not is_constant then (
+      fprintf ppf
+        "@,\
+         @,\
+         Possible solutions: @,\
+         - These types are compatible at runtime. You can use the coercion \
+         operator to convert to the expected type";
+      match suggested_rewrite with
+      | Some rewrite -> fprintf ppf ": @{<info>%s@}@," rewrite
+      | None -> fprintf ppf ": @{<info>:>@}@,")
   | _ -> ()
 
 let type_clash_context_from_function sexp sfunct =
diff --git a/compiler/ml/typecore.ml b/compiler/ml/typecore.ml
index de48f4b2c74..49ed9ea9731 100644
--- a/compiler/ml/typecore.ml
+++ b/compiler/ml/typecore.ml
@@ -4168,10 +4168,8 @@ let longident = Printtyp.longident
 let super_report_unification_error = Printtyp.super_report_unification_error
 let report_ambiguous_type_error = Printtyp.report_ambiguous_type_error
 let report_subtyping_error = Printtyp.report_subtyping_error
-let type_expr ppf typ =
-  (* print a type and avoid infinite loops *)
-  Printtyp.reset_and_mark_loops typ;
-  Printtyp.type_expr ppf typ
+
+let type_expr = Error_message_utils.type_expr
 
 let report_error env loc ppf error =
   match error with
diff --git a/tests/build_tests/super_errors/expected/subtype_record.res.expected b/tests/build_tests/super_errors/expected/subtype_record.res.expected
new file mode 100644
index 00000000000..4c34154326d
--- /dev/null
+++ b/tests/build_tests/super_errors/expected/subtype_record.res.expected
@@ -0,0 +1,15 @@
+
+  [1;31mWe've found a bug for you![0m
+  [36m/.../fixtures/subtype_record.res[0m:[2m23:23[0m
+
+  21 [2m│[0m }
+  22 [2m│[0m 
+  [1;31m23[0m [2m│[0m let x = takesSomeType([1;31mv[0m)
+  24 [2m│[0m 
+
+  This has type: [1;31msomeOtherType[0m
+  But this function argument is expecting: [1;33mSomeModule.someType[0m
+  
+  Possible solutions: 
+  - These types are compatible at runtime. You can use the coercion operator to convert to the expected type: [1;33m(v :> SomeModule.someType)
+[0m
\ No newline at end of file
diff --git a/tests/build_tests/super_errors/expected/subtype_string.res.expected b/tests/build_tests/super_errors/expected/subtype_string.res.expected
new file mode 100644
index 00000000000..47b413ded0d
--- /dev/null
+++ b/tests/build_tests/super_errors/expected/subtype_string.res.expected
@@ -0,0 +1,15 @@
+
+  [1;31mWe've found a bug for you![0m
+  [36m/.../fixtures/subtype_string.res[0m:[2m7:18-20[0m
+
+  5 [2m│[0m }
+  6 [2m│[0m 
+  [1;31m7[0m [2m│[0m let x = takesStr([1;31mOne[0m)
+  8 [2m│[0m 
+
+  This has type: [1;31mvariant[0m
+  But it's expected to have type: [1;33mstring[0m
+  
+  Possible solutions: 
+  - These types are compatible at runtime. You can use the coercion operator to convert to the expected type: [1;33m(One :> string)
+[0m
\ No newline at end of file
diff --git a/tests/build_tests/super_errors/fixtures/subtype_record.res b/tests/build_tests/super_errors/fixtures/subtype_record.res
new file mode 100644
index 00000000000..08ebb3f3854
--- /dev/null
+++ b/tests/build_tests/super_errors/fixtures/subtype_record.res
@@ -0,0 +1,23 @@
+module SomeModule = {
+  type someType = {
+    one: bool,
+    two: int,
+  }
+}
+
+type someOtherType = {
+  ...SomeModule.someType,
+  three: string,
+}
+
+let v = {
+  one: true,
+  two: 1,
+  three: "hello",
+}
+
+let takesSomeType = (s: SomeModule.someType) => {
+  s.one
+}
+
+let x = takesSomeType(v)
diff --git a/tests/build_tests/super_errors/fixtures/subtype_string.res b/tests/build_tests/super_errors/fixtures/subtype_string.res
new file mode 100644
index 00000000000..c41504c5c6d
--- /dev/null
+++ b/tests/build_tests/super_errors/fixtures/subtype_string.res
@@ -0,0 +1,7 @@
+type variant = One | Two
+
+let takesStr = (s: string) => {
+  s ++ "hello"
+}
+
+let x = takesStr(One)


Reviews:
user: login: cknitt
body: Great stuff, not sure about the cases for literals though.
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-23T06:18:26Z
user: login: zth
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-23T06:23:40Z
user: login: nojaf
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-23T06:39:08Z
user: login: zth
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-23T07:48:26Z
user: login: tsnobip
body: This is great @zth!
I'd just simplify the wording a bit to have less repetitions and as @cknitt said, I'd remove it for int literals.
Thanks a lot!

state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-23T08:07:23Z
user: login: zth
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-23T08:24:10Z
user: login: zth
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-23T09:28:41Z
user: login: tsnobip
body: looks perfect now @zth! 
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-23T11:32:07Z

Review comments:
diff_hunk: @@ -9,5 +9,11 @@
   This has type: [1;31mint[0m
   But it's expected to have type: [1;33mfloat[0m
   
+  Possible solutions: 
+  - These types are compatible at runtime. You can use the coercion operator [1;33m:>[0m to convert to the expected type [1;33mfloat[0m.
+    If you want to use coercion, rewrite the highlighted code to: [1;33m(2 :> float)
path: tests/build_tests/super_errors/expected/primitives1.res.expected
user: login: cknitt
body: Is this really a good suggestion for literals?

Wouldn't I want to write `2. + 2.` rather than `2. + (2 :> float)` to fix the error?
created_at: 2025-05-23T06:17:17Z
author_association: MEMBER
diff_hunk: @@ -9,5 +9,11 @@
   This has type: [1;31mint[0m
   But it's expected to have type: [1;33mfloat[0m
   
+  Possible solutions: 
+  - These types are compatible at runtime. You can use the coercion operator [1;33m:>[0m to convert to the expected type [1;33mfloat[0m.
+    If you want to use coercion, rewrite the highlighted code to: [1;33m(2 :> float)
path: tests/build_tests/super_errors/expected/primitives1.res.expected
user: login: zth
body: Yeah we could ignore these cases, agreed. The int/float/string suggestions need a refactor as well now that we have new capabilities, but we can save that for later. 
created_at: 2025-05-23T06:23:40Z
author_association: MEMBER
diff_hunk: @@ -9,5 +9,11 @@
   This has type: [1;31mint[0m
   But it's expected to have type: [1;33mfloat[0m
   
+  Possible solutions: 
+  - These types are compatible at runtime. You can use the coercion operator [1;33m:>[0m to convert to the expected type [1;33mfloat[0m.
+    If you want to use coercion, rewrite the highlighted code to: [1;33m(2 :> float)
path: tests/build_tests/super_errors/expected/primitives1.res.expected
user: login: nojaf
body: Oh wow, the penny didn't yet drop for me that you can write:
```rescript
let x = 4.
let y = 5
let z = x + (y :> float)
```

Really cool!
created_at: 2025-05-23T06:39:08Z
author_association: MEMBER
diff_hunk: @@ -9,5 +9,11 @@
   This has type: [1;31mint[0m
   But it's expected to have type: [1;33mfloat[0m
   
+  Possible solutions: 
+  - These types are compatible at runtime. You can use the coercion operator [1;33m:>[0m to convert to the expected type [1;33mfloat[0m.
+    If you want to use coercion, rewrite the highlighted code to: [1;33m(2 :> float)
path: tests/build_tests/super_errors/expected/primitives1.res.expected
user: login: zth
body: @nojaf yeah coercion (especially in v12) opens up a ton of cool possibilities actually. One example is that you can (together with variant type spreads) easily create fully type driven decoders:
```rescript
@unboxed
type someType = One | Two | Three | Four | Five

@unboxed
type someTypeStr = | ...someType | OtherString(string)

let someTypeFromString = (s: string) =>
  switch (s :> someTypeStr) {
  | ...someType as someType => Some(someType)
  | OtherString(_) => None
  }
```

```javascript
function someTypeFromString(s) {
  if (s === "One" || s === "Five" || s === "Two" || s === "Four" || s === "Three") {
    return s;
  } 
}
```

Can easily just add new cases to `someType` and have them automatically accounted for. Works because of the unboxed variant version of `someType` that has the `OtherString(string)` case, which covers any string that isn't already covered by the rest of the constructors.

Playground: https://rescript-lang.org/try?version=v12.0.0-alpha.13&module=esmodule&code=AIVwdgRg9gHgpgEwFABcCeAHOACAzlAWzgBVMcBebAeTBwB9tiB3KbB4gCwCc57sAxKCC5sBASwBucJElCRYiVGTyESZAMooRlBgDp9+IqSyiqKDnC6auYsAHMAFLi227AShkAbOChVGy-FyE1q7YlE4AXHgu9m5hAHxI2HhMYigAxhzYTtgR8X5qWNZxAN5Jovq6hoU4AIa4BcYU+eqqTqpNHskMZhZWMY4A+nHk+QByULTlAL5IQA
created_at: 2025-05-23T07:48:26Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,16 @@
+
+  [1;31mWe've found a bug for you![0m
+  [36m/.../fixtures/subtype_string.res[0m:[2m7:18-20[0m
+
+  5 [2m│[0m }
+  6 [2m│[0m 
+  [1;31m7[0m [2m│[0m let x = takesStr([1;31mOne[0m)
+  8 [2m│[0m 
+
+  This has type: [1;31mvariant[0m
+  But it's expected to have type: [1;33mstring[0m
+  
+  Possible solutions: 
+  - These types are compatible at runtime. You can use the coercion operator [1;33m:>[0m to convert to the expected type [1;33mstring[0m.
+    If you want to use coercion, rewrite the highlighted code to: [1;33m(One :> string)
path: tests/build_tests/super_errors/expected/subtype_string.res.expected
user: login: tsnobip
body: this is great! But I'd simplify the wording a bit, it feels a bit verbose:
```suggestion
  - These types are compatible at runtime. You can use the coercion operator to convert to the expected type with [1;33m(One :> string)
```
created_at: 2025-05-23T08:05:45Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,16 @@
+
+  [1;31mWe've found a bug for you![0m
+  [36m/.../fixtures/subtype_string.res[0m:[2m7:18-20[0m
+
+  5 [2m│[0m }
+  6 [2m│[0m 
+  [1;31m7[0m [2m│[0m let x = takesStr([1;31mOne[0m)
+  8 [2m│[0m 
+
+  This has type: [1;31mvariant[0m
+  But it's expected to have type: [1;33mstring[0m
+  
+  Possible solutions: 
+  - These types are compatible at runtime. You can use the coercion operator [1;33m:>[0m to convert to the expected type [1;33mstring[0m.
+    If you want to use coercion, rewrite the highlighted code to: [1;33m(One :> string)
path: tests/build_tests/super_errors/expected/subtype_string.res.expected
user: login: zth
body: @tsnobip how would you simplify the wording? Can't see anything along those lines in the diff above.
created_at: 2025-05-23T08:24:10Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,16 @@
+
+  [1;31mWe've found a bug for you![0m
+  [36m/.../fixtures/subtype_string.res[0m:[2m7:18-20[0m
+
+  5 [2m│[0m }
+  6 [2m│[0m 
+  [1;31m7[0m [2m│[0m let x = takesStr([1;31mOne[0m)
+  8 [2m│[0m 
+
+  This has type: [1;31mvariant[0m
+  But it's expected to have type: [1;33mstring[0m
+  
+  Possible solutions: 
+  - These types are compatible at runtime. You can use the coercion operator [1;33m:>[0m to convert to the expected type [1;33mstring[0m.
+    If you want to use coercion, rewrite the highlighted code to: [1;33m(One :> string)
path: tests/build_tests/super_errors/expected/subtype_string.res.expected
user: login: zth
body: Oh, I see now.
created_at: 2025-05-23T09:28:40Z
author_association: MEMBER
diff_hunk: @@ -0,0 +1,15 @@
+
+  [1;31mWe've found a bug for you![0m
+  [36m/.../fixtures/subtype_string.res[0m:[2m7:18-20[0m
+
+  5 [2m│[0m }
+  6 [2m│[0m 
+  [1;31m7[0m [2m│[0m let x = takesStr([1;31mOne[0m)
+  8 [2m│[0m 
+
+  This has type: [1;31mvariant[0m
+  But it's expected to have type: [1;33mstring[0m
+  
+  Possible solutions: 
+  - These types are compatible at runtime. You can use the coercion operator to convert to the expected type: [1;33m(One :> string)
+[0m
path: tests/build_tests/super_errors/expected/subtype_string.res.expected
user: login: tsnobip
body: perfect!
created_at: 2025-05-23T11:31:25Z
author_association: MEMBER

PR comments:
user: login: pkg-pr-new[bot]
created_at: 2025-05-22T19:13:31Z
body: 
[Open in StackBlitz](https://pkg.pr.new/template/aa618716-8eb6-4fbd-96f3-c2bc2f473fdb)


<details><summary><b>rescript</b></summary><p>

```
npm i https://pkg.pr.new/rescript-lang/rescript@7505
```

</p></details>
      
    

<details><summary><b>@rescript/darwin-x64</b></summary><p>

```
npm i https://pkg.pr.new/rescript-lang/rescript/@rescript/darwin-x64@7505
```

</p></details>
      
    

<details><summary><b>@rescript/darwin-arm64</b></summary><p>

```
npm i https://pkg.pr.new/rescript-lang/rescript/@rescript/darwin-arm64@7505
```

</p></details>
      
    

<details><summary><b>@rescript/linux-arm64</b></summary><p>

```
npm i https://pkg.pr.new/rescript-lang/rescript/@rescript/linux-arm64@7505
```

</p></details>
      
    

<details><summary><b>@rescript/linux-x64</b></summary><p>

```
npm i https://pkg.pr.new/rescript-lang/rescript/@rescript/linux-x64@7505
```

</p></details>
      
    

<details><summary><b>@rescript/win32-x64</b></summary><p>

```
npm i https://pkg.pr.new/rescript-lang/rescript/@rescript/win32-x64@7505
```

</p></details>
      
    

_commit: <a href="https://github.com/rescript-lang/rescript/runs/42773590086"><code>fb546a2</code></a>_

author_association: NONE
user: login: zth
created_at: 2025-05-23T09:33:23Z
body: @tsnobip look at the last commit please.
author_association: MEMBER