From 3e1c1d05a53584f6683028e223526925c17ab4d7 Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Tue, 9 Sep 2025 10:41:00 +0200 Subject: [PATCH 01/10] support inline record type spreads --- compiler/ml/record_type_spread.ml | 51 +++++++++++ compiler/ml/typedecl.ml | 79 +++++------------ compiler/syntax/src/res_core.ml | 85 ++++++++++++------- .../errors/other/expected/spread.res.txt | 15 +--- .../typexpr/expected/objectSpread.res.txt | 16 +--- 5 files changed, 129 insertions(+), 117 deletions(-) diff --git a/compiler/ml/record_type_spread.ml b/compiler/ml/record_type_spread.ml index 80dcbe9d5d..7c736f088d 100644 --- a/compiler/ml/record_type_spread.ml +++ b/compiler/ml/record_type_spread.ml @@ -89,3 +89,54 @@ let extract_type_vars (type_params : Types.type_expr list) | Tvar (Some tname) -> Some (tname, applied_tvar) | _ -> None) else [] + +let expand_labels_with_type_spreads ?(return_none_on_failure = false) + (env : Env.t) (lbls : Typedtree.label_declaration list) + (lbls' : Types.label_declaration list) = + match has_type_spread lbls with + | false -> Some (lbls, lbls') + | true -> ( + let rec extract (t : Types.type_expr) = + match t.desc with + | Tpoly (t, []) -> extract t + | _ -> Ctype.repr t + in + let mk_lbl (l : Types.label_declaration) (ld_type : Typedtree.core_type) + (type_vars : (string * Types.type_expr) list) : + Typedtree.label_declaration = + { + ld_id = l.ld_id; + ld_name = {txt = Ident.name l.ld_id; loc = l.ld_loc}; + ld_mutable = l.ld_mutable; + ld_optional = l.ld_optional; + ld_type = + {ld_type with ctyp_type = substitute_type_vars type_vars l.ld_type}; + ld_loc = l.ld_loc; + ld_attributes = l.ld_attributes; + } + in + let rec process_lbls acc (lbls : Typedtree.label_declaration list) + (lbls' : Types.label_declaration list) = + match (lbls, lbls') with + | {ld_name = {txt = "..."}; ld_type} :: rest, _ :: rest' -> ( + match + Ctype.extract_concrete_typedecl env (extract ld_type.ctyp_type) + with + | _p0, _p, {type_kind = Type_record (fields, _repr); type_params} -> + let type_vars = extract_type_vars type_params ld_type.ctyp_type in + process_lbls + ( fst acc @ Ext_list.map fields (fun l -> mk_lbl l ld_type type_vars), + snd acc + @ Ext_list.map fields (fun l -> + {l with ld_type = substitute_type_vars type_vars l.ld_type}) + ) + rest rest' + | _ -> None + | exception _ -> None) + | lbl :: rest, lbl' :: rest' -> + process_lbls (fst acc @ [lbl], snd acc @ [lbl']) rest rest' + | _ -> Some acc + in + match process_lbls ([], []) lbls lbls' with + | Some res -> Some res + | None -> if return_none_on_failure then None else Some (lbls, lbls')) diff --git a/compiler/ml/typedecl.ml b/compiler/ml/typedecl.ml index 360354600c..4108bd39d7 100644 --- a/compiler/ml/typedecl.ml +++ b/compiler/ml/typedecl.ml @@ -261,7 +261,24 @@ let transl_constructor_arguments env closed = function (Types.Cstr_tuple (List.map (fun t -> t.ctyp_type) l), Cstr_tuple l) | Pcstr_record l -> let lbls, lbls' = transl_labels env closed l in - (Types.Cstr_record lbls', Cstr_record lbls) + let expanded = + Record_type_spread.expand_labels_with_type_spreads + ~return_none_on_failure:true env lbls lbls' + in + (match expanded with + | Some (lbls, lbls') -> (Types.Cstr_record lbls', Cstr_record lbls) + | None -> ( + (* Ambiguous `{...t}`: if only spread present and it doesn't resolve to a + record type, treat it as an object-typed tuple argument. *) + match l with + | [{pld_name = {txt = "..."}; pld_type = spread_typ; _}] -> + let obj_ty = + Ast_helper.Typ.object_ ~loc:spread_typ.ptyp_loc + [Parsetree.Oinherit spread_typ] Asttypes.Closed + in + let cty = transl_simple_type env closed obj_ty in + (Types.Cstr_tuple [cty.ctyp_type], Cstr_tuple [cty]) + | _ -> (Types.Cstr_record lbls', Cstr_record lbls))) let make_constructor env type_path type_params sargs sret_type = match sret_type with @@ -582,64 +599,8 @@ let transl_declaration ~type_record_as_object ~untagged_wfc env sdecl id = transl_labels ~record_name:sdecl.ptype_name.txt env true lbls in let lbls_opt = - match Record_type_spread.has_type_spread lbls with - | true -> - let rec extract t = - match t.desc with - | Tpoly (t, []) -> extract t - | _ -> Ctype.repr t - in - let mk_lbl (l : Types.label_declaration) - (ld_type : Typedtree.core_type) - (type_vars : (string * Types.type_expr) list) : - Typedtree.label_declaration = - { - ld_id = l.ld_id; - ld_name = {txt = Ident.name l.ld_id; loc = l.ld_loc}; - ld_mutable = l.ld_mutable; - ld_optional = l.ld_optional; - ld_type = - { - ld_type with - ctyp_type = - Record_type_spread.substitute_type_vars type_vars l.ld_type; - }; - ld_loc = l.ld_loc; - ld_attributes = l.ld_attributes; - } - in - let rec process_lbls acc lbls lbls' = - match (lbls, lbls') with - | {ld_name = {txt = "..."}; ld_type} :: rest, _ :: rest' -> ( - match - Ctype.extract_concrete_typedecl env (extract ld_type.ctyp_type) - with - | _p0, _p, {type_kind = Type_record (fields, _repr); type_params} - -> - let type_vars = - Record_type_spread.extract_type_vars type_params - ld_type.ctyp_type - in - process_lbls - ( fst acc - @ Ext_list.map fields (fun l -> mk_lbl l ld_type type_vars), - snd acc - @ Ext_list.map fields (fun l -> - { - l with - ld_type = - Record_type_spread.substitute_type_vars type_vars - l.ld_type; - }) ) - rest rest' - | _ -> assert false - | exception _ -> None) - | lbl :: rest, lbl' :: rest' -> - process_lbls (fst acc @ [lbl], snd acc @ [lbl']) rest rest' - | _ -> Some acc - in - process_lbls ([], []) lbls lbls' - | false -> Some (lbls, lbls') + Record_type_spread.expand_labels_with_type_spreads + ~return_none_on_failure:true env lbls lbls' in let rec check_duplicates loc (lbls : Typedtree.label_declaration list) seen = diff --git a/compiler/syntax/src/res_core.ml b/compiler/syntax/src/res_core.ml index 805874b161..301283efba 100644 --- a/compiler/syntax/src/res_core.ml +++ b/compiler/syntax/src/res_core.ml @@ -149,10 +149,6 @@ module ErrorMessages = struct let string_interpolation_in_pattern = "String interpolation is not supported in pattern matching." - let spread_in_record_declaration = - "A record type declaration doesn't support the ... spread. Only an object \ - (with quoted field names) does." - let object_quoted_field_name name = "An object type declaration needs quoted field names. Did you mean \"" ^ name ^ "\"?" @@ -4983,40 +4979,65 @@ and parse_constr_decl_args p = (* start of object type spreading, e.g. `User({...a, "u": int})` *) Parser.next p; let typ = parse_typ_expr p in - let () = + let had_no_extra_fields = match p.token with | Rbrace -> (* {...x}, spread without extra fields *) - Parser.next p - | _ -> Parser.expect Comma p + Parser.next p; true + | _ -> Parser.expect Comma p; false in - let () = + (* Heuristic: if the next element starts with a string, it's an object field. + Otherwise, treat it as a record field start (attributes, mutable, + '...', keywords, or identifiers). This also covers cases where fields + have attributes or other qualifiers, not just bare Lidents. *) + let is_record_after_spread = match p.token with - | Lident _ -> - Parser.err ~start_pos:dotdotdot_start ~end_pos:dotdotdot_end p - (Diagnostics.message ErrorMessages.spread_in_record_declaration) - | _ -> () - in - let fields = - Parsetree.Oinherit typ - :: parse_comma_delimited_region - ~grammar:Grammar.StringFieldDeclarations ~closing:Rbrace - ~f:parse_string_field_declaration p - in - Parser.expect Rbrace p; - let loc = mk_loc start_pos p.prev_end_pos in - let typ = - Ast_helper.Typ.object_ ~loc fields Asttypes.Closed - |> parse_type_alias p - in - let typ = parse_arrow_type_rest ~es6_arrow:true ~start_pos typ p in - Parser.optional p Comma |> ignore; - let more_args = - parse_comma_delimited_region ~grammar:Grammar.TypExprList - ~closing:Rparen ~f:parse_typ_expr_region p + | String _ -> false + | _ -> true in - Parser.expect Rparen p; - Parsetree.Pcstr_tuple (typ :: more_args) + if is_record_after_spread || had_no_extra_fields then ( + (* Treat as record-style inline record with a type spread *) + let spread_field_name = + Location.mkloc "..." (mk_loc dotdotdot_start dotdotdot_end) + in + let spread_field_loc = mk_loc start_pos typ.ptyp_loc.loc_end in + let spread_field = + Ast_helper.Type.field ~attrs:[] ~loc:spread_field_loc + ~mut:Asttypes.Immutable spread_field_name typ + in + let more_fields = + if had_no_extra_fields then [] + else + parse_comma_delimited_region + ~grammar:Grammar.FieldDeclarations ~closing:Rbrace + ~f:parse_field_declaration_region p + in + if not had_no_extra_fields then Parser.expect Rbrace p; + Parser.optional p Comma |> ignore; + Parser.expect Rparen p; + Parsetree.Pcstr_record (spread_field :: more_fields)) + else + (* Object-style spread, remains a tuple arg of object type *) + let fields = + Parsetree.Oinherit typ + :: parse_comma_delimited_region + ~grammar:Grammar.StringFieldDeclarations ~closing:Rbrace + ~f:parse_string_field_declaration p + in + Parser.expect Rbrace p; + let loc = mk_loc start_pos p.prev_end_pos in + let typ = + Ast_helper.Typ.object_ ~loc fields Asttypes.Closed + |> parse_type_alias p + in + let typ = parse_arrow_type_rest ~es6_arrow:true ~start_pos typ p in + Parser.optional p Comma |> ignore; + let more_args = + parse_comma_delimited_region ~grammar:Grammar.TypExprList + ~closing:Rparen ~f:parse_typ_expr_region p + in + Parser.expect Rparen p; + Parsetree.Pcstr_tuple (typ :: more_args) | _ -> ( let attrs = parse_attributes p in match p.Parser.token with diff --git a/tests/syntax_tests/data/parsing/errors/other/expected/spread.res.txt b/tests/syntax_tests/data/parsing/errors/other/expected/spread.res.txt index adcec38799..2b33d97dbc 100644 --- a/tests/syntax_tests/data/parsing/errors/other/expected/spread.res.txt +++ b/tests/syntax_tests/data/parsing/errors/other/expected/spread.res.txt @@ -54,18 +54,6 @@ Solution: you need to pull out each field you want explicitly. List pattern matches only supports one `...` spread, at the end. Explanation: a list spread at the tail is efficient, but a spread in the middle would create new lists; out of performance concern, our pattern matching currently guarantees to never create new intermediate data. - - Syntax error! - syntax_tests/data/parsing/errors/other/spread.res:9:20 - - 7 │ - 8 │ type t = {...a} - 9 │ type t = Foo({...a}) - 10 │ type t = option - 11 │ - - I'm not sure what to parse here when looking at ")". - let [|arr;_|] = [|1;2;3|] let record = { x with y } let { x; y } = myRecord @@ -73,5 +61,6 @@ let x::y = myList type nonrec t = { ...: a } type nonrec t = - | Foo of < a > + | Foo of { + ...: a } type nonrec t = (foo, < x > ) option \ No newline at end of file diff --git a/tests/syntax_tests/data/parsing/errors/typexpr/expected/objectSpread.res.txt b/tests/syntax_tests/data/parsing/errors/typexpr/expected/objectSpread.res.txt index bd46eeae3c..99c4b518a8 100644 --- a/tests/syntax_tests/data/parsing/errors/typexpr/expected/objectSpread.res.txt +++ b/tests/syntax_tests/data/parsing/errors/typexpr/expected/objectSpread.res.txt @@ -1,16 +1,4 @@ - Syntax error! - syntax_tests/data/parsing/errors/typexpr/objectSpread.res:5:16-18 - - 3 │ type u = private {...a, u: int} - 4 │ - 5 │ type x = Type({...a, u: int}) - 6 │ - 7 │ type u = {...a, "u": int, v: int} - - A record type declaration doesn't support the ... spread. Only an object (with quoted field names) does. - - Syntax error! syntax_tests/data/parsing/errors/typexpr/objectSpread.res:9:14 @@ -28,6 +16,8 @@ type nonrec u = private { ...: a ; u: int } type nonrec x = - | Type of < a ;u: int > + | Type of { + ...: a ; + u: int } type nonrec u = < a ;u: int ;v: int > let f [arity:1](x : < a: int ;b: int > ) = () \ No newline at end of file From 738af668f9050d51d2a010b38e3193ce95fd9785 Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Tue, 9 Sep 2025 12:38:10 +0200 Subject: [PATCH 02/10] refactor for reuse --- compiler/syntax/src/res_core.ml | 97 +++++++++++++++++++-------------- 1 file changed, 55 insertions(+), 42 deletions(-) diff --git a/compiler/syntax/src/res_core.ml b/compiler/syntax/src/res_core.ml index 301283efba..3c9a3700f9 100644 --- a/compiler/syntax/src/res_core.ml +++ b/compiler/syntax/src/res_core.ml @@ -4979,24 +4979,12 @@ and parse_constr_decl_args p = (* start of object type spreading, e.g. `User({...a, "u": int})` *) Parser.next p; let typ = parse_typ_expr p in - let had_no_extra_fields = - match p.token with - | Rbrace -> - (* {...x}, spread without extra fields *) - Parser.next p; true - | _ -> Parser.expect Comma p; false - in - (* Heuristic: if the next element starts with a string, it's an object field. - Otherwise, treat it as a record field start (attributes, mutable, - '...', keywords, or identifiers). This also covers cases where fields - have attributes or other qualifiers, not just bare Lidents. *) - let is_record_after_spread = - match p.token with - | String _ -> false - | _ -> true + let res = + parse_spread_tail_classified ~start_pos ~spread_typ:typ + ~grammar:Grammar.FieldDeclarations p in - if is_record_after_spread || had_no_extra_fields then ( - (* Treat as record-style inline record with a type spread *) + (match res with + | `Record fields -> let spread_field_name = Location.mkloc "..." (mk_loc dotdotdot_start dotdotdot_end) in @@ -5005,39 +4993,18 @@ and parse_constr_decl_args p = Ast_helper.Type.field ~attrs:[] ~loc:spread_field_loc ~mut:Asttypes.Immutable spread_field_name typ in - let more_fields = - if had_no_extra_fields then [] - else - parse_comma_delimited_region - ~grammar:Grammar.FieldDeclarations ~closing:Rbrace - ~f:parse_field_declaration_region p - in - if not had_no_extra_fields then Parser.expect Rbrace p; Parser.optional p Comma |> ignore; Parser.expect Rparen p; - Parsetree.Pcstr_record (spread_field :: more_fields)) - else - (* Object-style spread, remains a tuple arg of object type *) - let fields = - Parsetree.Oinherit typ - :: parse_comma_delimited_region - ~grammar:Grammar.StringFieldDeclarations ~closing:Rbrace - ~f:parse_string_field_declaration p - in - Parser.expect Rbrace p; - let loc = mk_loc start_pos p.prev_end_pos in - let typ = - Ast_helper.Typ.object_ ~loc fields Asttypes.Closed - |> parse_type_alias p - in - let typ = parse_arrow_type_rest ~es6_arrow:true ~start_pos typ p in + Parsetree.Pcstr_record (spread_field :: fields) + | `Object typ -> Parser.optional p Comma |> ignore; let more_args = parse_comma_delimited_region ~grammar:Grammar.TypExprList ~closing:Rparen ~f:parse_typ_expr_region p in Parser.expect Rparen p; - Parsetree.Pcstr_tuple (typ :: more_args) + Parsetree.Pcstr_tuple (typ :: more_args)) + | _ -> ( let attrs = parse_attributes p in match p.Parser.token with @@ -5456,6 +5423,52 @@ and parse_type_equation_or_constr_decl p = (* TODO: is this a good idea? *) (None, Asttypes.Public, Parsetree.Ptype_abstract) +(* After parsing `{... `, parse the remainder inside the braces + and classify as record tail vs object tail. Returns: + - `Record fields`: list of record fields following the spread (may be empty) + - `Object typ`: an object type that inherits the spread and includes the tail + The caller is responsible for constructing the synthetic spread field when + building a record. *) +and parse_spread_tail_classified ?current_type_name_path ?inline_types_context + ~start_pos ~spread_typ ~grammar p = + match p.token with + | Rbrace -> + (* `{...t}` no extra fields: treat as record without tail fields *) + Parser.next p; + `Record [] + | _ -> + Parser.expect Comma p; + let found_object_field = ref false in + let (fields : Parsetree.label_declaration list) = + parse_comma_delimited_region ~grammar ~closing:Rbrace + ~f: + (parse_field_declaration_region ?current_type_name_path + ?inline_types_context ~found_object_field) + p + in + Parser.expect Rbrace p; + if !found_object_field then ( + (* Object-style: build an object type that inherits the spread *) + let obj_fields = + let convert (ld : Parsetree.label_declaration) = + let ({Parsetree.pld_name; pld_type; pld_attributes; _} : + Parsetree.label_declaration) = ld + in + match pld_name.txt with + | "..." -> Parsetree.Oinherit pld_type + | _ -> Otag (pld_name, pld_attributes, pld_type) + in + Parsetree.Oinherit spread_typ :: List.map convert fields + in + let loc = mk_loc start_pos p.prev_end_pos in + let typ = + Ast_helper.Typ.object_ ~loc obj_fields Asttypes.Closed + |> parse_type_alias p + in + let typ = parse_arrow_type_rest ~es6_arrow:true ~start_pos typ p in + `Object typ) + else `Record fields + and parse_record_or_object_decl ?current_type_name_path ?inline_types_context p = let start_pos = p.Parser.start_pos in From 23a78a6357807b826c4ffcb412824fdd4f7ac76a Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Tue, 9 Sep 2025 12:41:50 +0200 Subject: [PATCH 03/10] format --- compiler/ml/typedecl.ml | 7 ++++--- compiler/syntax/src/res_core.ml | 20 +++++++------------- 2 files changed, 11 insertions(+), 16 deletions(-) diff --git a/compiler/ml/typedecl.ml b/compiler/ml/typedecl.ml index 4108bd39d7..555a4830f2 100644 --- a/compiler/ml/typedecl.ml +++ b/compiler/ml/typedecl.ml @@ -259,13 +259,13 @@ let transl_constructor_arguments env closed = function | Pcstr_tuple l -> let l = List.map (transl_simple_type env closed) l in (Types.Cstr_tuple (List.map (fun t -> t.ctyp_type) l), Cstr_tuple l) - | Pcstr_record l -> + | Pcstr_record l -> ( let lbls, lbls' = transl_labels env closed l in let expanded = Record_type_spread.expand_labels_with_type_spreads ~return_none_on_failure:true env lbls lbls' in - (match expanded with + match expanded with | Some (lbls, lbls') -> (Types.Cstr_record lbls', Cstr_record lbls) | None -> ( (* Ambiguous `{...t}`: if only spread present and it doesn't resolve to a @@ -274,7 +274,8 @@ let transl_constructor_arguments env closed = function | [{pld_name = {txt = "..."}; pld_type = spread_typ; _}] -> let obj_ty = Ast_helper.Typ.object_ ~loc:spread_typ.ptyp_loc - [Parsetree.Oinherit spread_typ] Asttypes.Closed + [Parsetree.Oinherit spread_typ] + Asttypes.Closed in let cty = transl_simple_type env closed obj_ty in (Types.Cstr_tuple [cty.ctyp_type], Cstr_tuple [cty]) diff --git a/compiler/syntax/src/res_core.ml b/compiler/syntax/src/res_core.ml index 3c9a3700f9..535b62251a 100644 --- a/compiler/syntax/src/res_core.ml +++ b/compiler/syntax/src/res_core.ml @@ -4973,7 +4973,7 @@ and parse_constr_decl_args p = in Parser.expect Rparen p; Parsetree.Pcstr_tuple (typ :: more_args) - | DotDotDot -> + | DotDotDot -> ( let dotdotdot_start = p.start_pos in let dotdotdot_end = p.end_pos in (* start of object type spreading, e.g. `User({...a, "u": int})` *) @@ -4983,7 +4983,7 @@ and parse_constr_decl_args p = parse_spread_tail_classified ~start_pos ~spread_typ:typ ~grammar:Grammar.FieldDeclarations p in - (match res with + match res with | `Record fields -> let spread_field_name = Location.mkloc "..." (mk_loc dotdotdot_start dotdotdot_end) @@ -5004,7 +5004,6 @@ and parse_constr_decl_args p = in Parser.expect Rparen p; Parsetree.Pcstr_tuple (typ :: more_args)) - | _ -> ( let attrs = parse_attributes p in match p.Parser.token with @@ -5423,12 +5422,6 @@ and parse_type_equation_or_constr_decl p = (* TODO: is this a good idea? *) (None, Asttypes.Public, Parsetree.Ptype_abstract) -(* After parsing `{... `, parse the remainder inside the braces - and classify as record tail vs object tail. Returns: - - `Record fields`: list of record fields following the spread (may be empty) - - `Object typ`: an object type that inherits the spread and includes the tail - The caller is responsible for constructing the synthetic spread field when - building a record. *) and parse_spread_tail_classified ?current_type_name_path ?inline_types_context ~start_pos ~spread_typ ~grammar p = match p.token with @@ -5447,12 +5440,13 @@ and parse_spread_tail_classified ?current_type_name_path ?inline_types_context p in Parser.expect Rbrace p; - if !found_object_field then ( + if !found_object_field then (* Object-style: build an object type that inherits the spread *) let obj_fields = let convert (ld : Parsetree.label_declaration) = - let ({Parsetree.pld_name; pld_type; pld_attributes; _} : - Parsetree.label_declaration) = ld + let ({Parsetree.pld_name; pld_type; pld_attributes; _} + : Parsetree.label_declaration) = + ld in match pld_name.txt with | "..." -> Parsetree.Oinherit pld_type @@ -5466,7 +5460,7 @@ and parse_spread_tail_classified ?current_type_name_path ?inline_types_context |> parse_type_alias p in let typ = parse_arrow_type_rest ~es6_arrow:true ~start_pos typ p in - `Object typ) + `Object typ else `Record fields and parse_record_or_object_decl ?current_type_name_path ?inline_types_context p From cd2ecadf9b193615c4785a010042f176987a6183 Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Tue, 9 Sep 2025 14:07:32 +0200 Subject: [PATCH 04/10] refactor --- compiler/ml/record_type_spread.ml | 10 ++--- compiler/ml/typedecl.ml | 12 ++++-- compiler/syntax/src/res_core.ml | 56 +++++++++++++++++-------- tests/tests/src/object_type_spreads.mjs | 22 ++++++++++ tests/tests/src/object_type_spreads.res | 11 +++++ tests/tests/src/record_type_spread.mjs | 27 ++++++++++++ tests/tests/src/record_type_spread.res | 6 +++ 7 files changed, 116 insertions(+), 28 deletions(-) create mode 100644 tests/tests/src/object_type_spreads.mjs create mode 100644 tests/tests/src/object_type_spreads.res diff --git a/compiler/ml/record_type_spread.ml b/compiler/ml/record_type_spread.ml index 7c736f088d..bf7b286dd2 100644 --- a/compiler/ml/record_type_spread.ml +++ b/compiler/ml/record_type_spread.ml @@ -90,12 +90,12 @@ let extract_type_vars (type_params : Types.type_expr list) | _ -> None) else [] -let expand_labels_with_type_spreads ?(return_none_on_failure = false) - (env : Env.t) (lbls : Typedtree.label_declaration list) +let expand_labels_with_type_spreads (env : Env.t) + (lbls : Typedtree.label_declaration list) (lbls' : Types.label_declaration list) = match has_type_spread lbls with | false -> Some (lbls, lbls') - | true -> ( + | true -> let rec extract (t : Types.type_expr) = match t.desc with | Tpoly (t, []) -> extract t @@ -137,6 +137,4 @@ let expand_labels_with_type_spreads ?(return_none_on_failure = false) process_lbls (fst acc @ [lbl], snd acc @ [lbl']) rest rest' | _ -> Some acc in - match process_lbls ([], []) lbls lbls' with - | Some res -> Some res - | None -> if return_none_on_failure then None else Some (lbls, lbls')) + process_lbls ([], []) lbls lbls' diff --git a/compiler/ml/typedecl.ml b/compiler/ml/typedecl.ml index 555a4830f2..b1ce9d55d3 100644 --- a/compiler/ml/typedecl.ml +++ b/compiler/ml/typedecl.ml @@ -262,8 +262,7 @@ let transl_constructor_arguments env closed = function | Pcstr_record l -> ( let lbls, lbls' = transl_labels env closed l in let expanded = - Record_type_spread.expand_labels_with_type_spreads - ~return_none_on_failure:true env lbls lbls' + Record_type_spread.expand_labels_with_type_spreads env lbls lbls' in match expanded with | Some (lbls, lbls') -> (Types.Cstr_record lbls', Cstr_record lbls) @@ -600,8 +599,7 @@ let transl_declaration ~type_record_as_object ~untagged_wfc env sdecl id = transl_labels ~record_name:sdecl.ptype_name.txt env true lbls in let lbls_opt = - Record_type_spread.expand_labels_with_type_spreads - ~return_none_on_failure:true env lbls lbls' + Record_type_spread.expand_labels_with_type_spreads env lbls lbls' in let rec check_duplicates loc (lbls : Typedtree.label_declaration list) seen = @@ -780,6 +778,12 @@ let check_constraints ~type_record_as_object env sdecl (_, decl) = styl tyl | Cstr_record tyl, Pcstr_record styl -> check_constraints_labels env visited tyl styl + | ( Cstr_tuple [ty], + Pcstr_record [{pld_name = {txt = "..."}; pld_type; _}] ) -> + (* Ambiguous `{...t}` parsed as record with a single spread; typer may + reinterpret as an object tuple argument. Accept this and check the + single tuple arg against the source location of the spread type. *) + check_constraints_rec env pld_type.ptyp_loc visited ty | _ -> assert false); match (pcd_res, cd_res) with | Some sr, Some r -> check_constraints_rec env sr.ptyp_loc visited r diff --git a/compiler/syntax/src/res_core.ml b/compiler/syntax/src/res_core.ml index 535b62251a..0a6c270b02 100644 --- a/compiler/syntax/src/res_core.ml +++ b/compiler/syntax/src/res_core.ml @@ -4976,34 +4976,54 @@ and parse_constr_decl_args p = | DotDotDot -> ( let dotdotdot_start = p.start_pos in let dotdotdot_end = p.end_pos in - (* start of object type spreading, e.g. `User({...a, "u": int})` *) + (* start of spread, e.g. `User({...a, "u": int})` *) Parser.next p; - let typ = parse_typ_expr p in - let res = - parse_spread_tail_classified ~start_pos ~spread_typ:typ - ~grammar:Grammar.FieldDeclarations p - in - match res with - | `Record fields -> + let spread_typ = parse_typ_expr p in + match p.token with + | Rbrace -> + (* `{...t}` no tail: inline record with single spread *) + Parser.next p; let spread_field_name = Location.mkloc "..." (mk_loc dotdotdot_start dotdotdot_end) in - let spread_field_loc = mk_loc start_pos typ.ptyp_loc.loc_end in + let spread_field_loc = + mk_loc start_pos spread_typ.ptyp_loc.loc_end + in let spread_field = Ast_helper.Type.field ~attrs:[] ~loc:spread_field_loc - ~mut:Asttypes.Immutable spread_field_name typ + ~mut:Asttypes.Immutable spread_field_name spread_typ in Parser.optional p Comma |> ignore; Parser.expect Rparen p; - Parsetree.Pcstr_record (spread_field :: fields) - | `Object typ -> - Parser.optional p Comma |> ignore; - let more_args = - parse_comma_delimited_region ~grammar:Grammar.TypExprList - ~closing:Rparen ~f:parse_typ_expr_region p + Parsetree.Pcstr_record [spread_field] + | _ -> ( + let res = + parse_spread_tail_classified ~start_pos ~spread_typ + ~grammar:Grammar.FieldDeclarations p in - Parser.expect Rparen p; - Parsetree.Pcstr_tuple (typ :: more_args)) + match res with + | `Record fields -> + let spread_field_name = + Location.mkloc "..." (mk_loc dotdotdot_start dotdotdot_end) + in + let spread_field_loc = + mk_loc start_pos spread_typ.ptyp_loc.loc_end + in + let spread_field = + Ast_helper.Type.field ~attrs:[] ~loc:spread_field_loc + ~mut:Asttypes.Immutable spread_field_name spread_typ + in + Parser.optional p Comma |> ignore; + Parser.expect Rparen p; + Parsetree.Pcstr_record (spread_field :: fields) + | `Object typ -> + Parser.optional p Comma |> ignore; + let more_args = + parse_comma_delimited_region ~grammar:Grammar.TypExprList + ~closing:Rparen ~f:parse_typ_expr_region p + in + Parser.expect Rparen p; + Parsetree.Pcstr_tuple (typ :: more_args))) | _ -> ( let attrs = parse_attributes p in match p.Parser.token with diff --git a/tests/tests/src/object_type_spreads.mjs b/tests/tests/src/object_type_spreads.mjs new file mode 100644 index 0000000000..f95e52ba13 --- /dev/null +++ b/tests/tests/src/object_type_spreads.mjs @@ -0,0 +1,22 @@ +// Generated by ReScript, PLEASE EDIT WITH CARE + + +let a = { + TAG: "One", + _0: { + one: "1", + two: 2 + } +}; + +let b = { + TAG: "Two", + one: "1", + two: 2 +}; + +export { + a, + b, +} +/* No side effect */ diff --git a/tests/tests/src/object_type_spreads.res b/tests/tests/src/object_type_spreads.res new file mode 100644 index 0000000000..a55f543429 --- /dev/null +++ b/tests/tests/src/object_type_spreads.res @@ -0,0 +1,11 @@ +type a = {"one": string, "two": int} + +type b = { + one: string, + two: int, +} + +type variant = One({...a}) | Two({...b}) + +let a = One({"one": "1", "two": 2}) +let b = Two({one: "1", two: 2}) diff --git a/tests/tests/src/record_type_spread.mjs b/tests/tests/src/record_type_spread.mjs index e13971b417..8e2c6ecadd 100644 --- a/tests/tests/src/record_type_spread.mjs +++ b/tests/tests/src/record_type_spread.mjs @@ -41,6 +41,30 @@ let x = { c: "hello" }; +let v1 = { + TAG: "One", + a: "", + b: 1, + c: undefined, + d: undefined +}; + +let v2 = { + TAG: "Two", + a: "", + b: "1", + c: undefined, + d: undefined +}; + +let v3 = { + TAG: "Three", + a: "", + b: true, + c: undefined, + d: undefined +}; + export { getY, getX, @@ -48,5 +72,8 @@ export { d, x, DeepSub, + v1, + v2, + v3, } /* No side effect */ diff --git a/tests/tests/src/record_type_spread.res b/tests/tests/src/record_type_spread.res index ee373fb1ca..50c963bbb8 100644 --- a/tests/tests/src/record_type_spread.res +++ b/tests/tests/src/record_type_spread.res @@ -59,3 +59,9 @@ module DeepSub = { z: #Two(1), } } + +type variant<'f> = One({...f}) | Two({...f}) | Three({...f<'f>}) + +let v1 = One({a: "", b: 1, c: None, d: None}) +let v2 = Two({a: "", b: "1", c: None, d: None}) +let v3 = Three({a: "", b: true, c: None, d: None}) From d9331d2b739cc7069b797f504de98b48bae20a95 Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Tue, 9 Sep 2025 20:05:06 +0200 Subject: [PATCH 05/10] clearer comments --- compiler/ml/typedecl.ml | 4 ++-- compiler/syntax/src/res_core.ml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/compiler/ml/typedecl.ml b/compiler/ml/typedecl.ml index b1ce9d55d3..425654671c 100644 --- a/compiler/ml/typedecl.ml +++ b/compiler/ml/typedecl.ml @@ -267,10 +267,10 @@ let transl_constructor_arguments env closed = function match expanded with | Some (lbls, lbls') -> (Types.Cstr_record lbls', Cstr_record lbls) | None -> ( - (* Ambiguous `{...t}`: if only spread present and it doesn't resolve to a - record type, treat it as an object-typed tuple argument. *) match l with | [{pld_name = {txt = "..."}; pld_type = spread_typ; _}] -> + (* Ambiguous `{...t}`: if only spread present and it doesn't resolve to a + record type, treat it as an object-typed tuple argument. *) let obj_ty = Ast_helper.Typ.object_ ~loc:spread_typ.ptyp_loc [Parsetree.Oinherit spread_typ] diff --git a/compiler/syntax/src/res_core.ml b/compiler/syntax/src/res_core.ml index 0a6c270b02..6d203583cd 100644 --- a/compiler/syntax/src/res_core.ml +++ b/compiler/syntax/src/res_core.ml @@ -4981,7 +4981,7 @@ and parse_constr_decl_args p = let spread_typ = parse_typ_expr p in match p.token with | Rbrace -> - (* `{...t}` no tail: inline record with single spread *) + (* {...x}, spread without extra fields *) Parser.next p; let spread_field_name = Location.mkloc "..." (mk_loc dotdotdot_start dotdotdot_end) From 444cf36ba91836fde635dd016ff50d9df19a1e90 Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Tue, 9 Sep 2025 20:07:36 +0200 Subject: [PATCH 06/10] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 22769c4fd6..255f8b8463 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ - Fix code generation for emojis in polyvars and labels. https://github.com/rescript-lang/rescript/pull/7853 - Add `reset` to `experimental_features` to correctly reset playground. https://github.com/rescript-lang/rescript/pull/7868 - Fix crash with `@get` on external of type `unit => 'a`. https://github.com/rescript-lang/rescript/pull/7866 +- Fix record type spreads in inline records. https://github.com/rescript-lang/rescript/pull/7859 #### :memo: Documentation From ef337feb457e0e9cd75df4cf38c00b9b8fb4f76a Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Tue, 9 Sep 2025 22:01:27 +0200 Subject: [PATCH 07/10] fix disallowing mixing record and object spreads with fields of the other kind --- compiler/ml/typedecl.ml | 60 +++++++++++++------ .../mix_object_record_spread.res.expected | 12 ++++ .../mix_record_object_spread.res.expected | 11 ++++ .../fixtures/mix_object_record_spread.res | 11 ++++ .../fixtures/mix_record_object_spread.res | 3 + 5 files changed, 80 insertions(+), 17 deletions(-) create mode 100644 tests/build_tests/super_errors/expected/mix_object_record_spread.res.expected create mode 100644 tests/build_tests/super_errors/expected/mix_record_object_spread.res.expected create mode 100644 tests/build_tests/super_errors/fixtures/mix_object_record_spread.res create mode 100644 tests/build_tests/super_errors/fixtures/mix_record_object_spread.res diff --git a/compiler/ml/typedecl.ml b/compiler/ml/typedecl.ml index 425654671c..8077afb110 100644 --- a/compiler/ml/typedecl.ml +++ b/compiler/ml/typedecl.ml @@ -28,6 +28,7 @@ type error = | Repeated_parameter | Duplicate_constructor of string | Duplicate_label of string * string option + | Object_spread_with_record_field of string | Recursive_abbrev of string | Cycle_in_def of string * type_expr | Definition_mismatch of type_expr * Includecore.type_mismatch list @@ -623,24 +624,43 @@ let transl_declaration ~type_record_as_object ~untagged_wfc env sdecl id = else if optional then Record_regular else Record_regular ), sdecl ) - | None -> - (* Could not find record type decl for ...t: assume t is an object type and this is syntax ambiguity *) - type_record_as_object := true; - let fields = - Ext_list.map lbls_ (fun ld -> - match ld.pld_name.txt with - | "..." -> Parsetree.Oinherit ld.pld_type - | _ -> Otag (ld.pld_name, ld.pld_attributes, ld.pld_type)) + | None -> ( + (* Could not find record type decl for ...t. This happens when the spread + target is not a record type (e.g. an object type). If additional + fields are present in the record, this mixes a record field with an + object-type spread and should be rejected. If only the spread exists, + reinterpret as an object type for backwards compatibility. *) + (* TODO: We really really need to make this "spread that needs to be resolved" + concept 1st class in the AST or similar. This is quite hacky and fragile as + is.*) + let non_spread_field = + List.find_map + (fun ld -> if ld.pld_name.txt <> "..." then Some ld else None) + lbls_ in - let sdecl = - { - sdecl with - ptype_kind = Ptype_abstract; - ptype_manifest = - Some (Ast_helper.Typ.object_ ~loc:sdecl.ptype_loc fields Closed); - } - in - (Ttype_abstract, Type_abstract, sdecl)) + match non_spread_field with + | Some ld -> + (* Error on the first record field mixed with an object spread. *) + raise + (Error (ld.pld_loc, Object_spread_with_record_field ld.pld_name.txt)) + | None -> + (* Only a spread present: treat as object type (syntax ambiguity). *) + type_record_as_object := true; + let fields = + Ext_list.map lbls_ (fun ld -> + match ld.pld_name.txt with + | "..." -> Parsetree.Oinherit ld.pld_type + | _ -> Otag (ld.pld_name, ld.pld_attributes, ld.pld_type)) + in + let sdecl = + { + sdecl with + ptype_kind = Ptype_abstract; + ptype_manifest = + Some (Ast_helper.Typ.object_ ~loc:sdecl.ptype_loc fields Closed); + } + in + (Ttype_abstract, Type_abstract, sdecl))) | Ptype_open -> (Ttype_open, Type_open, sdecl) in let tman, man = @@ -2076,6 +2096,12 @@ let report_error ppf = function "The field @{%s@} is defined several times in this record. Fields \ can only be added once to a record." s + | Object_spread_with_record_field field_name -> + fprintf ppf + "@[You cannot mix a record field with an object type spread.@\n\ + Remove the record field or change it to an object field (e.g. \"%s\": \ + ...).@]" + field_name | Invalid_attribute msg -> fprintf ppf "%s" msg | Duplicate_label (s, Some record_name) -> fprintf ppf diff --git a/tests/build_tests/super_errors/expected/mix_object_record_spread.res.expected b/tests/build_tests/super_errors/expected/mix_object_record_spread.res.expected new file mode 100644 index 0000000000..ebc7705603 --- /dev/null +++ b/tests/build_tests/super_errors/expected/mix_object_record_spread.res.expected @@ -0,0 +1,12 @@ + + We've found a bug for you! + /.../fixtures/mix_object_record_spread.res:5:3-15 + + 3 │ type props = { + 4 │ ...baseProps, + 5 │ label: string, + 6 │ } + 7 │ + + You cannot mix a record field with an object type spread. + Remove the record field or change it to an object field (e.g. "label": ...). \ No newline at end of file diff --git a/tests/build_tests/super_errors/expected/mix_record_object_spread.res.expected b/tests/build_tests/super_errors/expected/mix_record_object_spread.res.expected new file mode 100644 index 0000000000..f16c77baa2 --- /dev/null +++ b/tests/build_tests/super_errors/expected/mix_record_object_spread.res.expected @@ -0,0 +1,11 @@ + + We've found a bug for you! + /.../fixtures/mix_record_object_spread.res:4:6-14 + + 2 │ + 3 │ type props = { + 4 │ ...baseProps, + 5 │ "label": string, + 6 │ } + + The type baseProps is not an object type \ No newline at end of file diff --git a/tests/build_tests/super_errors/fixtures/mix_object_record_spread.res b/tests/build_tests/super_errors/fixtures/mix_object_record_spread.res new file mode 100644 index 0000000000..2ec5288929 --- /dev/null +++ b/tests/build_tests/super_errors/fixtures/mix_object_record_spread.res @@ -0,0 +1,11 @@ +type baseProps = {"name": string} + +type props = { + ...baseProps, + label: string, +} + +let label: props = { + "name": "hello", + "label": "label", +} diff --git a/tests/build_tests/super_errors/fixtures/mix_record_object_spread.res b/tests/build_tests/super_errors/fixtures/mix_record_object_spread.res new file mode 100644 index 0000000000..3b4c53e5ed --- /dev/null +++ b/tests/build_tests/super_errors/fixtures/mix_record_object_spread.res @@ -0,0 +1,3 @@ +type baseProps = {name: string} + +type props = {...baseProps, "label": string} From d95d728b7c783784fbac3a01d584432b42d48d64 Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Tue, 9 Sep 2025 22:04:24 +0200 Subject: [PATCH 08/10] output --- .../expected/mix_record_object_spread.res.expected | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/tests/build_tests/super_errors/expected/mix_record_object_spread.res.expected b/tests/build_tests/super_errors/expected/mix_record_object_spread.res.expected index f16c77baa2..a3354bd70f 100644 --- a/tests/build_tests/super_errors/expected/mix_record_object_spread.res.expected +++ b/tests/build_tests/super_errors/expected/mix_record_object_spread.res.expected @@ -1,11 +1,10 @@ We've found a bug for you! - /.../fixtures/mix_record_object_spread.res:4:6-14 + /.../fixtures/mix_record_object_spread.res:3:18-26 + 1 │ type baseProps = {name: string} 2 │ - 3 │ type props = { - 4 │ ...baseProps, - 5 │ "label": string, - 6 │ } + 3 │ type props = {...baseProps, "label": string} + 4 │ The type baseProps is not an object type \ No newline at end of file From 699b429fdbe4386caabc90a12e1f89c2ea282bd6 Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Wed, 10 Sep 2025 15:04:12 +0200 Subject: [PATCH 09/10] cover another case --- compiler/ml/typedecl.ml | 42 +++++++++++++++---- ...ect_record_spread_constructor.res.expected | 11 +++++ .../mix_object_record_spread_constructor.res | 4 ++ 3 files changed, 49 insertions(+), 8 deletions(-) create mode 100644 tests/build_tests/super_errors/expected/mix_object_record_spread_constructor.res.expected create mode 100644 tests/build_tests/super_errors/fixtures/mix_object_record_spread_constructor.res diff --git a/compiler/ml/typedecl.ml b/compiler/ml/typedecl.ml index 8077afb110..da35de5288 100644 --- a/compiler/ml/typedecl.ml +++ b/compiler/ml/typedecl.ml @@ -256,6 +256,12 @@ let transl_labels ?record_name env closed lbls = in (lbls, lbls') +let first_non_spread_field (lbls_ : Parsetree.label_declaration list) = + List.find_map + (fun (ld : Parsetree.label_declaration) -> + if ld.pld_name.txt <> "..." then Some ld else None) + lbls_ + let transl_constructor_arguments env closed = function | Pcstr_tuple l -> let l = List.map (transl_simple_type env closed) l in @@ -271,7 +277,7 @@ let transl_constructor_arguments env closed = function match l with | [{pld_name = {txt = "..."}; pld_type = spread_typ; _}] -> (* Ambiguous `{...t}`: if only spread present and it doesn't resolve to a - record type, treat it as an object-typed tuple argument. *) + record type, treat it as an object-typed tuple argument. *) let obj_ty = Ast_helper.Typ.object_ ~loc:spread_typ.ptyp_loc [Parsetree.Oinherit spread_typ] @@ -279,7 +285,32 @@ let transl_constructor_arguments env closed = function in let cty = transl_simple_type env closed obj_ty in (Types.Cstr_tuple [cty.ctyp_type], Cstr_tuple [cty]) - | _ -> (Types.Cstr_record lbls', Cstr_record lbls))) + | _ -> ( + (* Could not resolve spread to a record type, but additional record + fields are present. Mirror declaration logic and reject mixing + object-type spreads with record fields. *) + match first_non_spread_field l with + | Some ld -> + raise + (Error (ld.pld_loc, Object_spread_with_record_field ld.pld_name.txt)) + | None -> ( + (* Be defensive: treat as an object-typed tuple if somehow only spreads + are present but not caught by the single-spread case. *) + let fields = + Ext_list.filter_map l (fun ld -> + match ld.pld_name.txt with + | "..." -> Some (Parsetree.Oinherit ld.pld_type) + | _ -> None) + in + match fields with + | [] -> (Types.Cstr_record lbls', Cstr_record lbls) + | _ -> + let obj_ty = + Ast_helper.Typ.object_ ~loc:(List.hd l).pld_loc fields + Asttypes.Closed + in + let cty = transl_simple_type env closed obj_ty in + (Types.Cstr_tuple [cty.ctyp_type], Cstr_tuple [cty]))))) let make_constructor env type_path type_params sargs sret_type = match sret_type with @@ -633,12 +664,7 @@ let transl_declaration ~type_record_as_object ~untagged_wfc env sdecl id = (* TODO: We really really need to make this "spread that needs to be resolved" concept 1st class in the AST or similar. This is quite hacky and fragile as is.*) - let non_spread_field = - List.find_map - (fun ld -> if ld.pld_name.txt <> "..." then Some ld else None) - lbls_ - in - match non_spread_field with + match first_non_spread_field lbls_ with | Some ld -> (* Error on the first record field mixed with an object spread. *) raise diff --git a/tests/build_tests/super_errors/expected/mix_object_record_spread_constructor.res.expected b/tests/build_tests/super_errors/expected/mix_object_record_spread_constructor.res.expected new file mode 100644 index 0000000000..2a8ec71ec0 --- /dev/null +++ b/tests/build_tests/super_errors/expected/mix_object_record_spread_constructor.res.expected @@ -0,0 +1,11 @@ + + We've found a bug for you! + /.../fixtures/mix_object_record_spread_constructor.res:4:16-28 + + 2 │ + 3 │ type t = + 4 │ | V({...obj, label: string}) + 5 │ + + You cannot mix a record field with an object type spread. + Remove the record field or change it to an object field (e.g. "label": ...). \ No newline at end of file diff --git a/tests/build_tests/super_errors/fixtures/mix_object_record_spread_constructor.res b/tests/build_tests/super_errors/fixtures/mix_object_record_spread_constructor.res new file mode 100644 index 0000000000..7e121652c9 --- /dev/null +++ b/tests/build_tests/super_errors/fixtures/mix_object_record_spread_constructor.res @@ -0,0 +1,4 @@ +type obj = {"name": string} + +type t = + | V({...obj, label: string}) From 5398f9d0f6147f2448659b589ead3e45bcaf9fe2 Mon Sep 17 00:00:00 2001 From: Gabriel Nordeborn Date: Wed, 10 Sep 2025 15:09:15 +0200 Subject: [PATCH 10/10] format --- .../mix_object_record_spread_constructor.res.expected | 8 ++++---- .../fixtures/mix_object_record_spread_constructor.res | 3 +-- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/tests/build_tests/super_errors/expected/mix_object_record_spread_constructor.res.expected b/tests/build_tests/super_errors/expected/mix_object_record_spread_constructor.res.expected index 2a8ec71ec0..87759f9d10 100644 --- a/tests/build_tests/super_errors/expected/mix_object_record_spread_constructor.res.expected +++ b/tests/build_tests/super_errors/expected/mix_object_record_spread_constructor.res.expected @@ -1,11 +1,11 @@ We've found a bug for you! - /.../fixtures/mix_object_record_spread_constructor.res:4:16-28 + /.../fixtures/mix_object_record_spread_constructor.res:3:21-33 + 1 │ type obj = {"name": string} 2 │ - 3 │ type t = - 4 │ | V({...obj, label: string}) - 5 │ + 3 │ type t = V({...obj, label: string}) + 4 │ You cannot mix a record field with an object type spread. Remove the record field or change it to an object field (e.g. "label": ...). \ No newline at end of file diff --git a/tests/build_tests/super_errors/fixtures/mix_object_record_spread_constructor.res b/tests/build_tests/super_errors/fixtures/mix_object_record_spread_constructor.res index 7e121652c9..910c48feca 100644 --- a/tests/build_tests/super_errors/fixtures/mix_object_record_spread_constructor.res +++ b/tests/build_tests/super_errors/fixtures/mix_object_record_spread_constructor.res @@ -1,4 +1,3 @@ type obj = {"name": string} -type t = - | V({...obj, label: string}) +type t = V({...obj, label: string})