From 7732f6859a132c6fac5b501e9791354dcadb4d73 Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Sat, 18 Apr 2026 16:59:02 +0200 Subject: [PATCH 1/4] propagate expected dict value type into dict literal typing --- compiler/ml/typecore.ml | 18 +++++++++++++++ .../dict_show_no_coercion.res.expected | 11 ++++----- tests/tests/src/DictScopedRecordLiteral.mjs | 23 +++++++++++++++++++ tests/tests/src/DictScopedRecordLiteral.res | 9 ++++++++ 4 files changed, 54 insertions(+), 7 deletions(-) create mode 100644 tests/tests/src/DictScopedRecordLiteral.mjs create mode 100644 tests/tests/src/DictScopedRecordLiteral.res diff --git a/compiler/ml/typecore.ml b/compiler/ml/typecore.ml index d7bc0aadaa2..3d53f0933b5 100644 --- a/compiler/ml/typecore.ml +++ b/compiler/ml/typecore.ml @@ -2259,6 +2259,15 @@ let extract_function_name funct = | Texp_ident (path, _, _) -> Some (Longident.parse (Path.name path)) | _ -> None +let is_primitive_dict_make = function + | { + pexp_desc = + Pexp_ident + {txt = Longident.Ldot (Longident.Lident "Primitive_dict", "make")}; + } -> + true + | _ -> false + type lazy_args = (Asttypes.arg_label * (unit -> Typedtree.expression) option) list @@ -2460,6 +2469,15 @@ and type_expect_ ?deprecated_context ~context ?in_function ?(recarg = Rejected) let funct = type_exp ~deprecated_context:FunctionCall ~context:None env sfunct in + (if is_primitive_dict_make sfunct then + (* Dict literals lower to Primitive_dict.make, so thread the expected + dict value type into the application before typing the tuple values. *) + let _, ty_res = + filter_arrow ~env + ~arity:(Some (List.length sargs)) + funct.exp_type Nolabel + in + unify_exp_types ~context:None loc env ty_res (instance env ty_expected)); let ty = instance env funct.exp_type in end_def (); wrap_trace_gadt_instances env (lower_args env []) ty; diff --git a/tests/build_tests/super_errors/expected/dict_show_no_coercion.res.expected b/tests/build_tests/super_errors/expected/dict_show_no_coercion.res.expected index 65445bd9d1f..ee47b4af0f3 100644 --- a/tests/build_tests/super_errors/expected/dict_show_no_coercion.res.expected +++ b/tests/build_tests/super_errors/expected/dict_show_no_coercion.res.expected @@ -1,15 +1,12 @@ We've found a bug for you! - /.../fixtures/dict_show_no_coercion.res:2:23-35 + /.../fixtures/dict_show_no_coercion.res:2:33-34 1 │ // This should not show coercion suggestion since just the inner types a │ re coercable, not the full type + expression (dict -> dict) - 2 │ let x: dict = dict{"1": 1.} + 2 │ let x: dict = dict{"1": 1.} 3 │ - This has type: dict - But it's expected to have type: dict - - The incompatible parts: - float vs JSON.t (defined as JSON.t) \ No newline at end of file + This has type: float + But it's expected to have type: JSON.t (defined as JSON.t) diff --git a/tests/tests/src/DictScopedRecordLiteral.mjs b/tests/tests/src/DictScopedRecordLiteral.mjs new file mode 100644 index 00000000000..baaaf006737 --- /dev/null +++ b/tests/tests/src/DictScopedRecordLiteral.mjs @@ -0,0 +1,23 @@ +// Generated by ReScript, PLEASE EDIT WITH CARE + + +let Hidden = {}; + +let dictValueInference = { + health: { + get: 200 + } +}; + +let primitiveMakeValueInference = { + health: { + get: 200 + } +}; + +export { + Hidden, + dictValueInference, + primitiveMakeValueInference, +} +/* No side effect */ diff --git a/tests/tests/src/DictScopedRecordLiteral.res b/tests/tests/src/DictScopedRecordLiteral.res new file mode 100644 index 00000000000..5d5c805a9ac --- /dev/null +++ b/tests/tests/src/DictScopedRecordLiteral.res @@ -0,0 +1,9 @@ +module Hidden = { + type routeHandlerObject = {get: int} +} + +let dictValueInference: Dict.t = dict{ + "health": {get: 200}, +} + +let primitiveMakeValueInference: Dict.t = dict{"health": {get: 200}} From f38b1897683c023a4eca7db3ee85c00c597c34ab Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Sat, 18 Apr 2026 17:38:27 +0200 Subject: [PATCH 2/4] cleaner impl --- compiler/ml/dict_type_helpers.ml | 11 +++++++++++ compiler/ml/typecore.ml | 13 ++----------- compiler/syntax/src/res_core.ml | 1 + compiler/syntax/src/res_parsetree_viewer.ml | 4 ++-- 4 files changed, 16 insertions(+), 13 deletions(-) diff --git a/compiler/ml/dict_type_helpers.ml b/compiler/ml/dict_type_helpers.ml index 0f5a807b3d1..7955bf65cba 100644 --- a/compiler/ml/dict_type_helpers.ml +++ b/compiler/ml/dict_type_helpers.ml @@ -28,9 +28,17 @@ A dict pattern is treated as a record pattern in the compiler and syntax, with an attriubute `@res.dictPattern` attached to it. This attribute is used to tell the compiler that the pattern is a dict pattern, and is what triggers the compiler to treat the dict record type differently to regular record types. + Dict expressions are lowered to `Primitive_dict.make`, with an internal attribute attached so typing can + recognize the construct without depending on the exact lowered callee path. *) let dict_magic_field_name = "dictValuesType" +let has_dict_literal_attribute attrs = + attrs + |> List.find_opt (fun (({txt}, _) : Parsetree.attribute) -> + txt = "res.$dictLiteral") + |> Option.is_some + let has_dict_pattern_attribute attrs = attrs |> List.find_opt (fun (({txt}, _) : Parsetree.attribute) -> @@ -47,3 +55,6 @@ let dict_attr : Parsetree.attribute = let dict_magic_field_attr : Parsetree.attribute = (Location.mknoloc "res.$dictMagicField", Parsetree.PStr []) + +let dict_literal_attr : Parsetree.attribute = + (Location.mknoloc "res.$dictLiteral", Parsetree.PStr []) diff --git a/compiler/ml/typecore.ml b/compiler/ml/typecore.ml index 3d53f0933b5..741902ea706 100644 --- a/compiler/ml/typecore.ml +++ b/compiler/ml/typecore.ml @@ -2259,15 +2259,6 @@ let extract_function_name funct = | Texp_ident (path, _, _) -> Some (Longident.parse (Path.name path)) | _ -> None -let is_primitive_dict_make = function - | { - pexp_desc = - Pexp_ident - {txt = Longident.Ldot (Longident.Lident "Primitive_dict", "make")}; - } -> - true - | _ -> false - type lazy_args = (Asttypes.arg_label * (unit -> Typedtree.expression) option) list @@ -2469,8 +2460,8 @@ and type_expect_ ?deprecated_context ~context ?in_function ?(recarg = Rejected) let funct = type_exp ~deprecated_context:FunctionCall ~context:None env sfunct in - (if is_primitive_dict_make sfunct then - (* Dict literals lower to Primitive_dict.make, so thread the expected + (if Dict_type_helpers.has_dict_literal_attribute sexp.pexp_attributes then + (* Dict literals lower to a regular application, so thread the expected dict value type into the application before typing the tuple values. *) let _, ty_res = filter_arrow ~env diff --git a/compiler/syntax/src/res_core.ml b/compiler/syntax/src/res_core.ml index 8583dab8fb7..e74ca0b8a54 100644 --- a/compiler/syntax/src/res_core.ml +++ b/compiler/syntax/src/res_core.ml @@ -4324,6 +4324,7 @@ and parse_dict_expr ~start_pos p = let key_value_pairs = List.filter_map to_key_value_pair rows in Parser.expect Rbrace p; Ast_helper.Exp.apply ~loc + ~attrs:[Dict_type_helpers.dict_literal_attr] (Ast_helper.Exp.ident ~loc (Location.mkloc (Longident.Ldot (Longident.Lident Primitive_modules.dict, "make")) diff --git a/compiler/syntax/src/res_parsetree_viewer.ml b/compiler/syntax/src/res_parsetree_viewer.ml index 391e51bede5..7c21270452a 100644 --- a/compiler/syntax/src/res_parsetree_viewer.ml +++ b/compiler/syntax/src/res_parsetree_viewer.ml @@ -250,7 +250,7 @@ let filter_parsing_attrs attrs = Location.txt = ( "res.braces" | "ns.braces" | "res.iflet" | "res.ternary" | "res.await" | "res.template" | "res.taggedTemplate" - | "res.patVariantSpread" | "res.dictPattern" + | "res.patVariantSpread" | "res.dictPattern" | "res.$dictLiteral" | "res.inlineRecordDefinition" ); }, _ ) -> @@ -585,7 +585,7 @@ let is_printable_attribute attr = Location.txt = ( "res.iflet" | "res.braces" | "ns.braces" | "JSX" | "res.await" | "res.template" | "res.taggedTemplate" | "res.ternary" - | "res.inlineRecordDefinition" ); + | "res.$dictLiteral" | "res.inlineRecordDefinition" ); }, _ ) -> false From 76863c63d9909274f6d36481900d2ce62f851c51 Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Sun, 19 Apr 2026 10:05:52 +0200 Subject: [PATCH 3/4] change approach back to structural, and fix async as well --- compiler/ml/dict_type_helpers.ml | 11 ----------- compiler/ml/typecore.ml | 21 ++++++++++++++++++--- compiler/syntax/src/res_core.ml | 1 - compiler/syntax/src/res_parsetree_viewer.ml | 4 ++-- tests/tests/src/DictScopedRecordLiteral.mjs | 7 +++++++ tests/tests/src/DictScopedRecordLiteral.res | 4 ++++ 6 files changed, 31 insertions(+), 17 deletions(-) diff --git a/compiler/ml/dict_type_helpers.ml b/compiler/ml/dict_type_helpers.ml index 7955bf65cba..0f5a807b3d1 100644 --- a/compiler/ml/dict_type_helpers.ml +++ b/compiler/ml/dict_type_helpers.ml @@ -28,17 +28,9 @@ A dict pattern is treated as a record pattern in the compiler and syntax, with an attriubute `@res.dictPattern` attached to it. This attribute is used to tell the compiler that the pattern is a dict pattern, and is what triggers the compiler to treat the dict record type differently to regular record types. - Dict expressions are lowered to `Primitive_dict.make`, with an internal attribute attached so typing can - recognize the construct without depending on the exact lowered callee path. *) let dict_magic_field_name = "dictValuesType" -let has_dict_literal_attribute attrs = - attrs - |> List.find_opt (fun (({txt}, _) : Parsetree.attribute) -> - txt = "res.$dictLiteral") - |> Option.is_some - let has_dict_pattern_attribute attrs = attrs |> List.find_opt (fun (({txt}, _) : Parsetree.attribute) -> @@ -55,6 +47,3 @@ let dict_attr : Parsetree.attribute = let dict_magic_field_attr : Parsetree.attribute = (Location.mknoloc "res.$dictMagicField", Parsetree.PStr []) - -let dict_literal_attr : Parsetree.attribute = - (Location.mknoloc "res.$dictLiteral", Parsetree.PStr []) diff --git a/compiler/ml/typecore.ml b/compiler/ml/typecore.ml index 741902ea706..f069a9412e0 100644 --- a/compiler/ml/typecore.ml +++ b/compiler/ml/typecore.ml @@ -2259,6 +2259,19 @@ let extract_function_name funct = | Texp_ident (path, _, _) -> Some (Longident.parse (Path.name path)) | _ -> None +let should_unify_expected_result_before_typing_lowered_apply funct sargs = + match (extract_function_name funct, sargs) with + | ( Some (Longident.Ldot (Longident.Lident "Primitive_dict", "make")), + [(Asttypes.Nolabel, {Parsetree.pexp_desc = Parsetree.Pexp_array _})] ) -> + (* Dict literals *) + true + | ( Some + (Longident.Ldot (Longident.Lident "Primitive_promise", "unsafe_async")), + [(Asttypes.Nolabel, _)] ) -> + (* Async wrapper *) + true + | _ -> false + type lazy_args = (Asttypes.arg_label * (unit -> Typedtree.expression) option) list @@ -2460,9 +2473,11 @@ and type_expect_ ?deprecated_context ~context ?in_function ?(recarg = Rejected) let funct = type_exp ~deprecated_context:FunctionCall ~context:None env sfunct in - (if Dict_type_helpers.has_dict_literal_attribute sexp.pexp_attributes then - (* Dict literals lower to a regular application, so thread the expected - dict value type into the application before typing the tuple values. *) + (if should_unify_expected_result_before_typing_lowered_apply funct sargs + then + (* Lowered syntax like dict literals and async wrappers becomes a regular + application, so thread the expected result type into the application + before typing its arguments. *) let _, ty_res = filter_arrow ~env ~arity:(Some (List.length sargs)) diff --git a/compiler/syntax/src/res_core.ml b/compiler/syntax/src/res_core.ml index e74ca0b8a54..8583dab8fb7 100644 --- a/compiler/syntax/src/res_core.ml +++ b/compiler/syntax/src/res_core.ml @@ -4324,7 +4324,6 @@ and parse_dict_expr ~start_pos p = let key_value_pairs = List.filter_map to_key_value_pair rows in Parser.expect Rbrace p; Ast_helper.Exp.apply ~loc - ~attrs:[Dict_type_helpers.dict_literal_attr] (Ast_helper.Exp.ident ~loc (Location.mkloc (Longident.Ldot (Longident.Lident Primitive_modules.dict, "make")) diff --git a/compiler/syntax/src/res_parsetree_viewer.ml b/compiler/syntax/src/res_parsetree_viewer.ml index 7c21270452a..391e51bede5 100644 --- a/compiler/syntax/src/res_parsetree_viewer.ml +++ b/compiler/syntax/src/res_parsetree_viewer.ml @@ -250,7 +250,7 @@ let filter_parsing_attrs attrs = Location.txt = ( "res.braces" | "ns.braces" | "res.iflet" | "res.ternary" | "res.await" | "res.template" | "res.taggedTemplate" - | "res.patVariantSpread" | "res.dictPattern" | "res.$dictLiteral" + | "res.patVariantSpread" | "res.dictPattern" | "res.inlineRecordDefinition" ); }, _ ) -> @@ -585,7 +585,7 @@ let is_printable_attribute attr = Location.txt = ( "res.iflet" | "res.braces" | "ns.braces" | "JSX" | "res.await" | "res.template" | "res.taggedTemplate" | "res.ternary" - | "res.$dictLiteral" | "res.inlineRecordDefinition" ); + | "res.inlineRecordDefinition" ); }, _ ) -> false diff --git a/tests/tests/src/DictScopedRecordLiteral.mjs b/tests/tests/src/DictScopedRecordLiteral.mjs index baaaf006737..2cb678f01c5 100644 --- a/tests/tests/src/DictScopedRecordLiteral.mjs +++ b/tests/tests/src/DictScopedRecordLiteral.mjs @@ -9,6 +9,12 @@ let dictValueInference = { } }; +async function asyncValueInference() { + return { + get: 200 + }; +} + let primitiveMakeValueInference = { health: { get: 200 @@ -18,6 +24,7 @@ let primitiveMakeValueInference = { export { Hidden, dictValueInference, + asyncValueInference, primitiveMakeValueInference, } /* No side effect */ diff --git a/tests/tests/src/DictScopedRecordLiteral.res b/tests/tests/src/DictScopedRecordLiteral.res index 5d5c805a9ac..e773d17728e 100644 --- a/tests/tests/src/DictScopedRecordLiteral.res +++ b/tests/tests/src/DictScopedRecordLiteral.res @@ -6,4 +6,8 @@ let dictValueInference: Dict.t = dict{ "health": {get: 200}, } +let asyncValueInference: unit => promise = async () => { + get: 200, +} + let primitiveMakeValueInference: Dict.t = dict{"health": {get: 200}} From 09b91a0a631dad0cb70b0b79bcfc237418accae0 Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Sun, 19 Apr 2026 10:08:56 +0200 Subject: [PATCH 4/4] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 59b711b45e1..bf81c46d851 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,7 @@ - Rewatch: preserve warnings after atomic-save full rebuilds. https://github.com/rescript-lang/rescript/pull/8358 - Preserve JSX prop locations across the AST0 translation layer, fixing `0:0` editor diagnostics in PPX-related flows. https://github.com/rescript-lang/rescript/pull/8350 +- Fix type lowering for `dict{}` and `async`, so you don't need to annotate one extra time when the type is known. https://github.com/rescript-lang/rescript/pull/8359 #### :memo: Documentation