Skip to content

Conversation

@brianrourkeboll
Copy link
Contributor

@brianrourkeboll brianrourkeboll commented Sep 22, 2025

Description

Add support for the spread operator ... in record types and expressions (nominal and anonymous).

type R1      = { A : int; B : int }
type R2      = { C : int; D : int }
type R3      = { ...R1; ...R2; E : int } // { A : int; B : int; C : int; D : int; E : int }

let r1       = { A = 1; B = 2 }
let r2       = { C = 3; D = 4 }
let r3       = { ...r1; ...r2; E = 5 }   // { A = 1; B = 2; C = 3; D = 4; E = 5 }

let r1' : R1 = { ...r3;  B = 99 }        // { A = 1; B = 99 }
let r2'      = {| ...r2 |}               // {| C = 3; D = 4 |}
let r3'      = { ...r1'; ...r2' }        // { A = 1; B = 99; C = 3; D = 4 }
let r3''     = {| ...r1; ...r2 |}        // {| A = 1; B = 2; C = 3; D = 4 |}
let r3'''    = {| ...r1'; ...r2' |}      // {| A = 1; B = 99; C = 3; D = 4 |}

This PR is meant to begin probing the "spread operator for objects" space — especially the set algebra and associated mechanics — while leaving room for implementing more of the scenarios outlined in fsharp/fslang-suggestions#1253 later. For example, should this prove viable, I would expect one of the next additions to be support for spreading non-records into records, i.e., mapping regular class/struct/interface properties/fields to record fields; this PR explicitly disallows that to ensure that we are free to add it later.

Checklist

  • Release notes entry updated
  • Fix any regressions
  • Enough™ tests
    • Parsing and error recovery
    • Set algebra
    • Accessibility
    • Mutability
    • Generics
    • Recursion
    • Effects
    • Allowed and disallowed sources
    • Conversions/coercions
    • Emitted IL
      • Attribute shadowing
      • Most of the rest of the above
  • Add placeholder for without in parser? I had this originally but later removed it. I am unsure without is really needed or worth the complexity — but if we want to keep our options open, I will add it back in.
  • RFC: [RFC FS-1151] Spread operator for F# fsharp/fslang-design#805
  • Address remaining TODOs (mostly AST traversal branches)
  • Clean up/refactor

Feature overview

I hope that the tests can serve as a reasonable overview of the feature and its behavior. If you see any glaring omissions, or if the tests are unclear or incomplete, please let me know. (I see that I still need to add tests for coercions/conversions…)

Parsing & error recovery

module Parsing =
[<Fact>]
let ``{...} → error`` () =
let src =
"""
type R1 = { A : int; B : int }
type R2 = { ... }
let r1 : R1 = { ... }
let r2 = {| ... |}
let r1' : R1 = { r1 with ... }
let r2' = {| r1 with ... |}
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldFail
|> withDiagnostics [
Error 3885, Line 3, Col 29, Line 3, Col 32, "Missing spread source type after '...'."
Error 3884, Line 4, Col 33, Line 4, Col 36, "Missing spread source expression after '...'."
Error 3884, Line 5, Col 29, Line 5, Col 32, "Missing spread source expression after '...'."
Error 3884, Line 6, Col 42, Line 6, Col 45, "Missing spread source expression after '...'."
Error 3884, Line 7, Col 38, Line 7, Col 41, "Missing spread source expression after '...'."
]

Record type spreads: set algebra

module Algebra =
/// No overlap, spread ⊕ field.
[<Fact>]
let ``{...{A,B},C} = {A,B}{C} = {A,B,C}`` () =
let src =
"""
type R1 = { A : int; B : int }
type R2 = { ...R1; C : int }
let _ : R2 = { A = 1; B = 2; C = 3 }
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldSucceed
/// No overlap, spread from anonymous record ⊕ field.
[<Fact>]
let ``{...{|A,B|},C} = {A,B}{C} = {A,B,C}`` () =
let src =
"""
type R2 = { ...{| A : int; B : int |}; C : int }
let _ : R2 = { A = 1; B = 2; C = 3 }
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldSucceed
/// No overlap, field ⊕ spread.
[<Fact>]
let ``{A,...{B,C}} = {A}{B,C} = {A,B,C}`` () =
let src =
"""
type R1 = { B : int; C : int }
type R2 = { A : int; ...R1 }
let _ : R2 = { A = 1; B = 2; C = 3 }
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldSucceed
/// No overlap, spread ⊕ spread.
[<Fact>]
let ``{...{A,B},...{C,D}} = {A,B}{C,D} = {A,B,C,D}`` () =
let src =
"""
type R1 = { A : int; B : int }
type R2 = { C : int; D : int }
type R3 = { ...R1; ...R2 }
let _ : R3 = { A = 1; B = 2; C = 3; D = 4 }
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldSucceed
/// Rightward explicit duplicate field shadows field from spread.
[<Fact>]
let ``{...{A₀,B},A} = {A₀,B}{A₁} = {A₁,B,C}`` () =
let src =
"""
type R1 = { A : int; B : int }
type R2 = { ...R1; A : string }
let _ : R2 = { A = "1"; B = 2 }
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldSucceed
/// Rightward spread field shadows leftward spread field.
[<Fact>]
let ``{...{A₀,B},...{A₁}} = {A₀,B}{A₁} = {A₁,B,C}`` () =
let src =
"""
type R1 = { A : int; B : int }
type R2 = { A : string }
type R3 = { ...R1; ...R2 }
type R4 = { ...R2; ...R1 }
let _ : R3 = { A = "1"; B = 2 }
let _ : R4 = { A = 1; B = 2 }
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldSucceed
/// Rightward spread field shadows leftward explicit field with warning.
[<Fact>]
let ``{A₀,...{A₁,B}} = {A₀}{A₁,B} = {A₁_warn,B,C}`` () =
let src =
"""
type R1 = { A : int; B : int }
type R2 = { A : string; ...R1 }
let _ : R2 = { A = 1; B = 2 }
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldFail
|> withSingleDiagnostic (Warning 3882, Line 3, Col 45, Line 3, Col 50, "Spread field 'A: int' from type 'R1' shadows an explicitly declared field with the same name.")
/// Explicit duplicate fields remain disallowed.
[<Fact>]
let ``{A₀,...{A₁,B},A₂} = {A₀}{A₁,B}{A₂} = {A₁_warn,B,A₂_error}`` () =
let src =
"""
type R1 = { A : int; B : int }
type R2 = { A : string; ...R1; A : float }
let _ : R2 = { A = 1; B = 2 }
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldFail
|> withDiagnostics [
Warning 3882, Line 3, Col 45, Line 3, Col 50, "Spread field 'A: int' from type 'R1' shadows an explicitly declared field with the same name."
Error 37, Line 3, Col 52, Line 3, Col 53, "Duplicate definition of field 'A'"
]
[<Fact>]
let ``No dupes allowed, multiple`` () =
let src =
"""
type R1 = { A : int; B : string }
type R2 = { A : decimal }
type R3 = { ...R2; A : string; ...R1; A : float }
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldFail
|> withDiagnostics [
Warning 3882, Line 4, Col 52, Line 4, Col 57, "Spread field 'A: int' from type 'R1' shadows an explicitly declared field with the same name."
Error 37, Line 4, Col 59, Line 4, Col 60, "Duplicate definition of field 'A'"
]

Record type spreads: accessibility

module Accessibility =
/// Fields should have the accessibility of the target type.
/// A spread from less to more accessible is valid as long as the less accessible
/// fields are accessible at the point of the spread.
[<Fact>]
let ``Accessibility comes from target`` () =
let src =
"""
type private R1 = { A : int; B : string }
type public R2 = { ...R1 }
let public r2 : R2 = { A = 1; B = "2" }
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldSucceed

Record type spreads: mutability

module Mutability =
[<Fact>]
let ``Mutability is brought over`` () =
let src =
"""
type R1 = { A : int; mutable B : string }
type R2 = { ...R1 }
let r2 : R2 = { A = 1; B = "3" }
r2.B <- "99"
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldSucceed

Record type spreads: generic type parameters

module GenericTypeParameters =
[<Fact>]
let ``Single type parameter, inferred at usage`` () =
let src =
"""
type R1<'a> = { A : 'a; B : string }
type R2<'a> = { X : 'a; Y : string }
type R3<'a> = { ...R1<'a>; ...R2<'a> }
let _ : R3<_> = { A = 3; B = "lol"; X = 4; Y = "haha" }
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldSucceed
[<Fact>]
let ``Single type parameter, annotated at usage`` () =
let src =
"""
type R1<'a> = { A : 'a; B : string }
type R2<'a> = { X : 'a; Y : string }
type R3<'a> = { ...R1<'a>; ...R2<'a> }
let _ : R3<int> = { A = 3; B = "lol"; X = 4; Y = "haha" }
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldSucceed
[<Fact>]
let ``Multiple type parameters`` () =
let src =
"""
type R1<'a> = { A : 'a; B : string }
type R2<'a> = { X : 'a; Y : string }
type R3<'a, 'b> = { ...R1<'a>; ...R2<'b> }
let _ : R3<_, _> = { A = 3; B = "lol"; X = 3.14; Y = "haha" }
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldSucceed
[<Fact>]
let ``'a → 'a list`` () =
let src =
"""
type R1<'a> = { A : 'a }
type R2<'a> = { ...R1<'a list> }
let _ : R2<int> = { A = [3] }
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldSucceed
[<Fact>]
let ``Single type parameter, not in scope, not allowed`` () =
let src =
"""
type R1<'a> = { A : 'a; B : string }
type R2<'a> = { X : 'a; Y : string }
type R3<'a> = { ...R1<'a>; ...R2<'b> }
type R4 = { ...R1<'a>; ...R2<'b> }
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldFail
|> withDiagnostics [
Error 39, Line 4, Col 54, Line 4, Col 56, "The type parameter 'b is not defined."
Error 39, Line 5, Col 39, Line 5, Col 41, "The type parameter 'a is not defined."
Error 39, Line 5, Col 50, Line 5, Col 52, "The type parameter 'b is not defined."
]
/// Akin to:
///
/// type R1<[<Measure>] 'a> = { A : int<'a> }
/// type R2<'a> = { X : R1<'a> }
[<Fact>]
let ``Measure attribute on source, required on spread destination`` () =
let src =
"""
type R1<[<Measure>] 'a> = { A : int<'a> }
type R2<'a> = { ...R1<'a> }
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldFail
|> withSingleDiagnostic (Error 702, Line 3, Col 43, Line 3, Col 45, "Expected unit-of-measure parameter, not type parameter. Explicit unit-of-measure parameters must be marked with the [<Measure>] attribute.")
[<Fact>]
let ``Measure attribute on source, measure on spread destination, OK`` () =
let src =
"""
type R1<[<Measure>] 'a> = { A : int<'a> }
type R2<[<Measure>] 'b> = { ...R1<'b> }
type [<Measure>] m
type R3 = { ...R1<m> }
let _ : R1<m> = { A = 3<m> }
let _ : R2<m> = { A = 3<m> }
let _ : R3 = { A = 3<m> }
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldSucceed
/// Akin to:
///
/// type R1<'a when 'a : comparison> = { A : 'a }
/// type R2<'a> = { X : R1<'a> }
[<Fact>]
let ``Constraint on source, required on spread destination`` () =
let src =
"""
type R1<'a when 'a : comparison> = { A : 'a }
type R2<'a> = { ...R1<'a> }
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldFail
|> withSingleDiagnostic (Error 1, Line 3, Col 40, Line 3, Col 46, "A type parameter is missing a constraint 'when 'a: comparison'")
[<Fact>]
let ``Constraint on source, required on spread destination, error if not compatible at usage`` () =
let src =
"""
type R1<'a when 'a : comparison> = { A : 'a list }
type R2<'a when 'a : comparison > = { ...R1<'a> }
let _ : R2<_> = { A = [obj ()] }
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldFail
|> withSingleDiagnostic (Error 193, Line 5, Col 44, Line 5, Col 50, "The type 'obj' does not support the 'comparison' constraint. For example, it does not support the 'System.IComparable' interface")
[<Fact>]
let ``Constraint on source, constraint on spread destination, compatible at usage, OK`` () =
let src =
"""
type R1<'a when 'a : comparison> = { A : 'a }
type R2<'a when 'a : comparison> = { ...R1<'a> }
type R3<'a when 'a : comparison> = { ...R1<'a list> }
let _ : R1<int> = { A = 3 }
let _ : R2<int> = { A = 3 }
let _ : R3<int list> = { A = [3] }
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldSucceed

Record type spreads: non-record source (not allowed)

module NonRecordSource =
[<Fact>]
let ``{...class}error`` () =
let src =
"""
type C () =
member _.A = 1
member _.B = 2
type R = { ...C }
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldFail
|> withSingleDiagnostic (Error 3879, Line 6, Col 32, Line 6, Col 36, "The source type of a spread into a record type definition must itself be a nominal or anonymous record type.")
[<Fact>]
let ``{...abstract_class}error`` () =
let src =
"""
[<AbstractClass>]
type C () =
abstract A : int
default _.A = 1
abstract B : int
default _.B = 2
type R = { ...C }
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldFail
|> withSingleDiagnostic (Error 3879, Line 9, Col 32, Line 9, Col 36, "The source type of a spread into a record type definition must itself be a nominal or anonymous record type.")
[<Fact>]
let ``{...struct}error`` () =
let src =
"""
[<Struct>]
type S =
member _.A = 1
member _.B = 2
type R = { ...S }
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldFail
|> withSingleDiagnostic (Error 3879, Line 7, Col 32, Line 7, Col 36, "The source type of a spread into a record type definition must itself be a nominal or anonymous record type.")
[<Fact>]
let ``{...interface}error`` () =
let src =
"""
type IFace =
abstract A : int
abstract B : int
type R = { ...IFace }
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldFail
|> withSingleDiagnostic (Error 3879, Line 6, Col 32, Line 6, Col 40, "The source type of a spread into a record type definition must itself be a nominal or anonymous record type.")
[<Fact>]
let ``{...int}error`` () =
let src =
"""
type R = { ...int }
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldFail
|> withSingleDiagnostic (Error 3879, Line 2, Col 32, Line 2, Col 38, "The source type of a spread into a record type definition must itself be a nominal or anonymous record type.")
[<Fact>]
let ``{...(int -> int)}error`` () =
let src =
"""
type R = { ...(int -> int) }
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldFail
|> withSingleDiagnostic (Error 3879, Line 2, Col 32, Line 2, Col 47, "The source type of a spread into a record type definition must itself be a nominal or anonymous record type.")

Record type spreads: recursion (cycles not allowed)

module Recursion =
[<Fact>]
let ``Mutually recursive type spreads error`` () =
let src =
"""
type R = { A : int; ...S; B : int }
and S = { C : int; ...R; D : int }
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldFail
|> withDiagnostics [
Error 3886, Line 2, Col 26, Line 2, Col 27, "This type definition involves a cyclic reference through a spread."
Error 3886, Line 3, Col 26, Line 3, Col 27, "This type definition involves a cyclic reference through a spread."
]
[<Fact>]
let ``Mutually recursive type spreads with some indirection error`` () =
let src =
"""
type R = { A : int; ...S }
and S = { B : int; ...T }
and T = { C : int; ...U }
and U = { D : int; ...R }
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldFail
|> withDiagnostics [
Error 3886, Line 2, Col 26, Line 2, Col 27, "This type definition involves a cyclic reference through a spread."
Error 3886, Line 3, Col 26, Line 3, Col 27, "This type definition involves a cyclic reference through a spread."
Error 3886, Line 4, Col 26, Line 4, Col 27, "This type definition involves a cyclic reference through a spread."
Error 3886, Line 5, Col 26, Line 5, Col 27, "This type definition involves a cyclic reference through a spread."
]
[<Fact>]
let ``Mutually recursive type spreads in recursive module error`` () =
let src =
"""
module rec M
type R = { A : int; ...S; B : int }
type S = { C : int; ...R; D : int }
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldFail
|> withDiagnostics [
Error 3886, Line 4, Col 26, Line 4, Col 27, "This type definition involves a cyclic reference through a spread."
Error 3886, Line 5, Col 26, Line 5, Col 27, "This type definition involves a cyclic reference through a spread."
]
[<Fact>]
let ``Complex mutually recursive type spreads error`` () =
let src =
"""
module rec M
[<AutoOpen>]
module N =
type R = { A : int; ...O.S }
module O =
type S = { B : int; ...T }
type T = { C : int; ...U }
[<AutoOpen>]
module P =
[<AutoOpen>]
module Q =
type U = { D : int; ...R }
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldFail
|> withDiagnostics [
Error 3886, Line 6, Col 30, Line 6, Col 31, "This type definition involves a cyclic reference through a spread."
Error 3886, Line 9, Col 34, Line 9, Col 35, "This type definition involves a cyclic reference through a spread."
Error 3886, Line 11, Col 26, Line 11, Col 27, "This type definition involves a cyclic reference through a spread."
Error 3886, Line 17, Col 34, Line 17, Col 35, "This type definition involves a cyclic reference through a spread."
]
[<Fact>]
let ``Mutually recursive type defns with spreads, no cycles success`` () =
let src =
"""
module M =
type R = { α : int }
and S = { β : int }
and T = { γ : int }
and U = { δ : int }
type R = { A : int; ...M.S }
and S = { B : int; ...M.T }
and T = { C : int; ...M.U }
and U = { D : int; ...M.R }
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldSucceed

Anonymous record expression spreads: set algebra

module Algebra =
/// No overlap, spread ⊕ field.
[<Fact>]
let ``{...{A,B},C} = {A,B}{C} = {A,B,C}`` () =
let src =
"""
let r1 = {| A = 1; B = 2 |}
let r2 : {| A : int ; B : int; C : int |} = {| ...r1; C = 3 |}
let r2' : {| A : int ; B : int; C : int |} = {| {||} with ...r1; C = 3 |}
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldSucceed
/// No overlap, field ⊕ spread.
[<Fact>]
let ``{A,...{B,C}} = {A}{B,C} = {A,B,C}`` () =
let src =
"""
let r1 = {| A = 1; B = 2 |}
let r2 : {| A : int ; B : int; C : int |} = {| C = 3; ...r1 |}
let r2' : {| A : int ; B : int; C : int |} = {| {||} with C = 3; ...r1 |}
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldSucceed
/// No overlap, spread ⊕ spread.
[<Fact>]
let ``{...{A,B},...{C,D}} = {A,B}{C,D} = {A,B,C,D}`` () =
let src =
"""
let r1 = {| A = 1 ; B = 2 |}
let r2 = {| C = 3; D = 4 |}
let r3 : {| A : int ; B : int; C : int; D : int |} = {| ...r1; ...r2 |}
let r4 : {| A : int ; B : int; C : int; D : int |} = {| ...r2; ...r3 |}
let r3' : {| A : int ; B : int; C : int; D : int |} = {| {||} with ...r1; ...r2 |}
let r4' : {| A : int ; B : int; C : int; D : int |} = {| {||} with ...r2; ...r3 |}
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldSucceed
/// Rightward explicit duplicate field shadows field from spread.
[<Fact>]
let ``{...{A₀,B},A} = {A₀,B}{A₁} = {A₁,B,C}`` () =
let src =
"""
let r1 = {| A = 1; B = 2 |}
let r2 : {| A : string; B : int |} = {| ...r1; A = "A" |}
let r2' : {| A : string; B : int |} = {| {||} with ...r1; A = "A" |}
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldSucceed
/// Rightward spread field shadows leftward spread field.
[<Fact>]
let ``{...{A₀,B},...{A₁}} = {A₀,B}{A₁} = {A₁,B,C}`` () =
let src =
"""
let r1 = {| A = 1; B = 2 |}
let r2 = {| A = "A" |}
let r3 : {| A : string; B : int |} = {| ...r1; ...r2 |}
let r4 : {| A : int; B : int |} = {| ...r2; ...r1 |}
let r3' : {| A : string; B : int |} = {| {||} with ...r1; ...r2 |}
let r4' : {| A : int; B : int |} = {| {||} with ...r2; ...r1 |}
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldSucceed
/// Rightward spread field shadows leftward explicit field with warning.
[<Fact>]
let ``{A₀,...{A₁,B}} = {A₀}{A₁,B} = {A₁_warn,B,C}`` () =
let src =
"""
let r1 = {| A = 1; B = 2 |}
let r2 : {| A : int; B : int |} = {| A = "A"; ...r1 |}
let r2' : {| A : int; B : int |} = {| {||} with A = "A"; ...r1 |}
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldFail
|> withDiagnostics [
Warning 3883, Line 4, Col 67, Line 4, Col 72, "Spread field 'A: int' shadows an explicitly declared field with the same name."
Warning 3883, Line 5, Col 78, Line 5, Col 83, "Spread field 'A: int' shadows an explicitly declared field with the same name."
]
/// Explicit duplicate fields remain disallowed.
[<Fact>]
let ``{A₀,...{A₁,B},A₂} = {A₀}{A₁,B}{A₂} = {A₁_warn,B,A₂_error}`` () =
let src =
"""
let r1 = {| A = 1; B = 2 |}
let r2 = {| A = "A"; ...r1; A = 3.14 |}
let r2' = {| {||} with A = "A"; ...r1; A = 3.14 |}
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldFail
|> withDiagnostics [
Warning 3883, Line 4, Col 42, Line 4, Col 47, "Spread field 'A: int' shadows an explicitly declared field with the same name."
Error 3522, Line 4, Col 49, Line 4, Col 57, "The field 'A' appears multiple times in this record expression."
Error 3522, Line 5, Col 31, Line 5, Col 71, "The field 'A' appears multiple times in this record expression."
]
[<Fact>]
let ``No dupes allowed, multiple`` () =
let src =
"""
let r1 = {| A = 1; B = "B" |}
let r2 = {| A = 3m |}
let r3 = {| ...r2; A = "A"; ...r1; A = 3.14 |}
let r3' = {| {||} with ...r2; A = "A"; ...r1; A = 3.14 |}
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldFail
|> withDiagnostics [
Warning 3883, Line 5, Col 49, Line 5, Col 54, "Spread field 'A: int' shadows an explicitly declared field with the same name."
Error 3522, Line 5, Col 56, Line 5, Col 64, "The field 'A' appears multiple times in this record expression."
Error 3522, Line 6, Col 31, Line 6, Col 78, "The field 'A' appears multiple times in this record expression."
]
[<Fact>]
let ``{...{A,B,C}}:{B} = {A,B,C}{B} = {B}`` () =
let src =
"""
let src = {| A = 1; B = "B"; C = 3m |}
let typedTarget : {| B : string |} = {| ...src |}
let typedTarget' : {| B : string |} = {| {||} with ...src |}
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldSucceed
[<Fact>]
let ``{...{}} = ∅ ⊕ ∅ = ∅`` () =
let src =
"""
module M
let r = {| ...{||} |}
let r' = {| {||} with ...{||} |}
if r <> {||} then failwith $"Expected {{||}} but got %A{r}."
if r' <> {||} then failwith $"Expected {{||}} but got %A{r'}."
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> asExe
|> compileAndRun
|> shouldSucceed

Anonymous record expression spreads: accessibility

module Accessibility =
/// Fields should have the accessibility of the target type.
/// A spread from less to more accessible is valid as long as the less accessible
/// fields are accessible at the point of the spread.
[<Fact>]
let ``Accessibility comes from target`` () =
let src =
"""
let private r1 = {| A = 1; B = "B" |}
let public r2 : {| A : int; B : string |} = {| ...r1 |}
let public r2' : {| A : int; B : string |} = {| {||} with ...r1 |}
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldSucceed

Anonymous record expression spreads: mutability

module Mutability =
[<Fact>]
let ``Mutability is _not_ brought over`` () =
let src =
"""
type R1 = { A : int; mutable B : string }
let r1 = { A = 1; B = "B" }
let r2 = {| ...r1 |}
r2.B <- "99"
let r2' = {| {||} with ...r1 |}
r2'.B <- "99"
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldFail
|> withDiagnostics [
Error 799, Line 6, Col 21, Line 6, Col 25, "Invalid assignment"
Error 799, Line 9, Col 21, Line 9, Col 26, "Invalid assignment"
]

Anonymous record expression spreads: generic type parameters

module GenericTypeParameters =
[<Fact>]
let ``Single type parameter`` () =
let src =
"""
let f (x : 'a) =
let r1 : {| A : 'a; B : string |} = {| A = x; B = "B" |}
let r2 : {| X : 'a; Y : string |} = {| X = x; Y = "Y" |}
let r3 : {| A : 'a; B : string; X : 'a; Y : string |} = {| ...r1; ...r2 |}
let r3' : {| A : 'a; B : string; X : 'a; Y : string |} = {| {||} with ...r1; ...r2 |}
r3, r3'
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldSucceed
[<Fact>]
let ``Multiple type parameters`` () =
let src =
"""
let r1 (x : 'a) = {| A = x; B = "B" |}
let r2 (x : 'a) = {| X = x; Y = "Y" |}
let r3 (x : 'a) (y : 'b) : {| A : 'a; B : string; X : 'b; Y : string |} = {| ...r1 x; ...r2 y |}
let r3' (x : 'a) (y : 'b) : {| A : 'a; B : string; X : 'b; Y : string |} = {| {||} with ...r1 x; ...r2 y |}
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldSucceed
[<Fact>]
let ``Measure attribute on source, present on spread destination`` () =
let src =
"""
let r1 (r2 : {| A : int<'m> |}) : {| A : int<'m> |} = {| ...r2 |}
let r1' (r2 : {| A : int<'m> |}) : {| A : int<'m> |} = {| {||} with ...r2 |}
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldSucceed
[<Fact>]
let ``Constraints kept`` () =
let src =
"""
let r1<'a when 'a : comparison> (r2 : {| A : 'a |}) : unit -> {| A : 'a |} = fun () -> {| ...r2 |}
let r1'<'a when 'a : comparison> (r2 : {| A : 'a |}) : unit -> {| A : 'a |} = fun () -> {| {||} with ...r2 |}
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldSucceed

Anonymous record expression spreads: non-record-source (not currently allowed)

module NonRecordSource =
[<Fact>]
let ``{...class}error`` () =
let src =
"""
type C () =
member _.A = 1
member _.B = 2
let r = {| ...C () |}
let r' = {| {||} with ...C () |}
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldFail
|> withDiagnostics [
Error 3881, Line 6, Col 35, Line 6, Col 39, "The source expression of a spread into an anonymous record expression must have a nominal or anonymous record type."
Error 3881, Line 7, Col 43, Line 7, Col 50, "The source expression of a spread into an anonymous record expression must have a nominal or anonymous record type."
]
[<Fact>]
let ``{...abstract_class}error`` () =
let src =
"""
[<AbstractClass>]
type C () =
abstract A : int
abstract B : int
let r =
{|
...
{ new C () with
member _.A = 1
member _.B = 2 }
|}
let r' =
{|
{||} with
...
{ new C () with
member _.A = 1
member _.B = 2 }
|}
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldFail
|> withDiagnostics [
Error 3881, Line 10, Col 33, Line 12, Col 53, "The source expression of a spread into an anonymous record expression must have a nominal or anonymous record type."
Error 3881, Line 18, Col 33, Line 21, Col 57, "The source expression of a spread into an anonymous record expression must have a nominal or anonymous record type."
]
[<Fact>]
let ``{...struct}error`` () =
let src =
"""
[<Struct>]
type S =
member _.A = 1
member _.B = 2
let r = {| ...S () |}
let r' = {| {||} with ...S () |}
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldFail
|> withDiagnostics [
Error 3881, Line 7, Col 35, Line 7, Col 39, "The source expression of a spread into an anonymous record expression must have a nominal or anonymous record type."
Error 3881, Line 8, Col 43, Line 8, Col 50, "The source expression of a spread into an anonymous record expression must have a nominal or anonymous record type."
]
[<Fact>]
let ``{...interface}error`` () =
let src =
"""
type IFace =
abstract A : int
abstract B : int
let r =
{|
...
{ new IFace with
member _.A = 1
member _.B = 2 }
|}
let r' =
{|
{||} with
...
{ new IFace with
member _.A = 1
member _.B = 2 }
|}
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldFail
|> withDiagnostics [
Error 3881, Line 9, Col 33, Line 11, Col 53, "The source expression of a spread into an anonymous record expression must have a nominal or anonymous record type."
Error 3881, Line 17, Col 33, Line 20, Col 57, "The source expression of a spread into an anonymous record expression must have a nominal or anonymous record type."
]
[<Fact>]
let ``{...int}error`` () =
let src =
"""
let r = {| ...0 |}
let r' = {| {||} with ...0 |}
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldFail
|> withDiagnostics [
Error 3881, Line 2, Col 35, Line 2, Col 36, "The source expression of a spread into an anonymous record expression must have a nominal or anonymous record type."
Error 3881, Line 3, Col 43, Line 3, Col 47, "The source expression of a spread into an anonymous record expression must have a nominal or anonymous record type."
]
[<Fact>]
let ``{...(int -> int)}error`` () =
let src =
"""
let r = {| ...(fun x -> x + 1) |}
let r' = {| {||} with ...(fun x -> x + 1) |}
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldFail
|> withDiagnostics [
Error 3881, Line 2, Col 35, Line 2, Col 51, "The source expression of a spread into an anonymous record expression must have a nominal or anonymous record type."
Error 3881, Line 3, Col 43, Line 3, Col 62, "The source expression of a spread into an anonymous record expression must have a nominal or anonymous record type."
]
[<Fact>]
let ``{...int list}error`` () =
let src =
"""
let r = {| ...[1..10] |}
let r' = {| {||} with ...[1..10] |}
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldFail
|> withDiagnostics [
Error 3881, Line 2, Col 35, Line 2, Col 42, "The source expression of a spread into an anonymous record expression must have a nominal or anonymous record type."
Error 3881, Line 3, Col 43, Line 3, Col 53, "The source expression of a spread into an anonymous record expression must have a nominal or anonymous record type."
]

Anonymous record expression spreads: non-field members on record sources are ignored

module MembersOtherThanRecordFields =
[<Fact>]
let ``Instance properties that are not record fields are ignored`` () =
let src =
"""
type R1 =
{ A : int
B : string }
member this.Lol = string this.A + this.B
type R2 = { ...R1; C : string }
let r1 = { A = 3; B = "3"; C = "asdf" }
let r2 : {| A : int; B : string; C : string |} = {| ...r1 |}
let r2' : {| A : int; B : string; C : string |} = {| {||} with ...r1 |}
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldSucceed
[<Fact>]
let ``All members other than record fields are ignored`` () =
let src =
"""
type R1 =
{ A : int
B : int }
member this.Lol = this.A + this.B
member _.Ha () = ()
static member X = "3"
static member val Y = 42
static member Q () = ()
type R2 = { ...R1; C : string }
let r2 : R2 = { A = 3; B = 3; C = "asdf" }
let r3 : {| A : int; B : int; C : string |} = {| ...r2 |}
let r3' : {| A : int; B : int; C : string |} = {| {||} with ...r2 |}
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldSucceed

Anonymous record expression spreads: effects

module Effects =
[<Fact>]
let ``Effects in spread sources are evaluated exactly once per spread, even if all fields are shadowed`` () =
let src =
"""
let effects = ResizeArray ()
let f () = effects.Add "f"; {| A = 0; B = 1 |}
let g () = effects.Add "g"; {| A = 2; B = 3 |}
let h () = effects.Add "h"; {| A = 99 |}
let r = {| ...g (); ...g (); ...h (); A = 100 |}
let r' = {| f () with ...g (); ...g (); ...h (); A = 100 |}
if r.A <> 100 then failwith $"Expected r.A = 100 but got %d{r.A}."
if r'.A <> 100 then failwith $"Expected r'.A = 100 but got %d{r'.A}."
match List.ofSeq effects with
| ["g"; "g"; "h"; "f"; "g"; "g"; "h"] -> ()
| unexpected -> failwith $"Expected [\"g\"; \"g\"; \"h\"; \"f\"; \"g\"; \"g\"; \"h\"] but got %A{unexpected}."
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> compileExeAndRun
|> shouldSucceed

Nominal record expression spreads: set algebra

module Algebra =
/// No overlap, spread ⊕ field.
[<Fact>]
let ``{...{A,B},C} = {A,B}{C} = {A,B,C}`` () =
let src =
"""
type R1 = { A : int; B : int }
type R2 = { A : int; B : int; C : int }
let r1 = { A = 1; B = 2 }
let r2 = { ...r1; C = 3 }
let r1' = {| A = 1; B = 2 |}
let r2' = { ...r1; C = 3 }
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldSucceed
/// No overlap, field ⊕ spread.
[<Fact>]
let ``{A,...{B,C}} = {A}{B,C} = {A,B,C}`` () =
let src =
"""
type R1 = { B : int; C : int }
type R2 = { A : int; B : int; C : int }
let r1 = { B = 1; C = 2 }
let r2 = { A = 3; ...r1 }
let r1' = {| B = 1; C = 2 |}
let r2' = { A = 3; ...r1 }
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldSucceed
/// No overlap, spread ⊕ spread.
[<Fact>]
let ``{...{A,B},...{C,D}} = {A,B}{C,D} = {A,B,C,D}`` () =
let src =
"""
type R1 = { A : int; B : int }
type R2 = { C : int; D : int }
type R3 = { A : int; B : int; C : int; D : int }
let r1 = { A = 1; B = 2 }
let r2 = { C = 3; D = 4 }
let r3 = { ...r1; ...r2 }
let r3' = { ...r2; ...r3 }
let r1' = {| A = 1; B = 2 |}
let r2' = {| C = 3; D = 4 |}
let r3'' = { ...r1; ...r2 }
let r3''' = { ...r2; ...r3 }
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldSucceed
/// Rightward explicit duplicate field shadows field from spread.
[<Fact>]
let ``{...{A₀,B},A} = {A₀,B}{A₁} = {A₁,B,C}`` () =
let src =
"""
module M
type R1 = { A : int; B : int }
let r1 = { A = 1; B = 2 }
let r1' = { ...r1; A = 99 }
if r1'.A <> 99 then failwith $"Expected r1'.A = 99 but got %A{r1'.A}."
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> asExe
|> compileAndRun
|> shouldSucceed
/// Rightward spread field shadows leftward spread field.
[<Fact>]
let ``{...{A₀,B},...{A₁}} = {A₀,B}{A₁} = {A₁,B,C}`` () =
let src =
"""
module M
type R1 = { A : int; B : int }
let r1 = { A = 1; B = 2 }
let r1' = { ...r1; ...{| A = 99 |} }
if r1'.A <> 99 then failwith $"Expected r1'.A = 99 but got %A{r1'.A}."
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> asExe
|> compileAndRun
|> shouldSucceed
/// Rightward spread field shadows leftward explicit field with warning.
[<Fact>]
let ``{A₀,...{A₁,B}} = {A₀}{A₁,B} = {A₁_warn,B,C}`` () =
let src =
"""
type R1 = { A : int; B : int }
let r1 = { A = 1; B = 2 }
let r1' = { A = 0; ...r1 }
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldFail
|> withSingleDiagnostic (Warning 3883, Line 5, Col 40, Line 5, Col 45, "Spread field 'A: int' shadows an explicitly declared field with the same name.")
/// Explicit duplicate fields remain disallowed.
[<Fact>]
let ``{A₀,...{A₁,B},A₂} = {A₀}{A₁,B}{A₂} = {A₁_warn,B,A₂_error}`` () =
let src =
"""
type R1 = { A : int; B : int }
let r1 = { A = 1; B = 2; A = 3; ...{| A = 4 |}; A = 5 }
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldFail
|> withDiagnostics [
Error 668, Line 4, Col 46, Line 4, Col 51, "The field 'A' appears multiple times in this record expression or pattern"
Warning 3883, Line 4, Col 53, Line 4, Col 67, "Spread field 'A: int' shadows an explicitly declared field with the same name."
Error 668, Line 4, Col 69, Line 4, Col 74, "The field 'A' appears multiple times in this record expression or pattern"
]
/// Extra fields are ignored.
[<Fact>]
let ``{...{A,B,C}}:{B} = {A,B,C}{B} = {B}`` () =
let src =
"""
type R1 = { A : int; B : int; C : int }
type R2 = { B : int }
let r1 = { A = 1; B = 2; C = 3 }
let r2 : R2 = { ...r1 }
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldSucceed

Nominal record expression spreads: accessibility

module Accessibility =
/// Fields should have the accessibility of the target type.
/// A spread from less to more accessible is valid as long as the less accessible
/// fields are accessible at the point of the spread.
[<Fact>]
let ``Accessibility comes from target`` () =
let src =
"""
type private R1 = { A : int; B : string }
type public R2 = { ...R1 }
let private r1 = { A = 1; B = "2" }
let public r2 : R2 = { ...r1 }
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldSucceed

Nominal record expression spreads: non-recourd source (not currently allowed)

module NonRecordSource =
[<Fact>]
let ``{...class}error`` () =
let src =
"""
type C () =
member _.A = 1
member _.B = 2
type R = { A : int }
let r : R = { ...C () }
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldFail
|> withDiagnostics [
Error 3880, Line 8, Col 35, Line 8, Col 42, "The source expression of a spread into a nominal record expression must have a nominal or anonymous record type."
Error 764, Line 8, Col 33, Line 8, Col 44, "No assignment given for field 'A' of type 'Test.R'"
]
[<Fact>]
let ``{...abstract_class}error`` () =
let src =
"""
[<AbstractClass>]
type C () =
abstract A : int
abstract B : int
type R = { A : int }
let r : R =
{
...
{ new C () with
member _.A = 1
member _.B = 2 }
}
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldFail
|> withDiagnostics [
Error 3880, Line 11, Col 29, Line 14, Col 53, "The source expression of a spread into a nominal record expression must have a nominal or anonymous record type."
Error 764, Line 10, Col 25, Line 15, Col 26, "No assignment given for field 'A' of type 'Test.R'"
]
[<Fact>]
let ``{...struct}error`` () =
let src =
"""
[<Struct>]
type S =
member _.A = 1
member _.B = 2
type R = { A : int }
let r : R = { ...S () }
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldFail
|> withDiagnostics [
Error 3880, Line 9, Col 35, Line 9, Col 42, "The source expression of a spread into a nominal record expression must have a nominal or anonymous record type."
Error 764, Line 9, Col 33, Line 9, Col 44, "No assignment given for field 'A' of type 'Test.R'"
]
[<Fact>]
let ``{...interface}error`` () =
let src =
"""
type IFace =
abstract A : int
abstract B : int
type R = { A : int }
let r : R =
{
...
{ new IFace with
member _.A = 1
member _.B = 2 }
}
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldFail
|> withDiagnostics [
Error 3880, Line 10, Col 29, Line 13, Col 53, "The source expression of a spread into a nominal record expression must have a nominal or anonymous record type."
Error 764, Line 9, Col 25, Line 14, Col 26, "No assignment given for field 'A' of type 'Test.R'"
]
[<Fact>]
let ``{...int}error`` () =
let src =
"""
type R = { A : int }
let r : R = { ...int }
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldFail
|> withDiagnostics [
Error 3880, Line 4, Col 35, Line 4, Col 41, "The source expression of a spread into a nominal record expression must have a nominal or anonymous record type."
Error 764, Line 4, Col 33, Line 4, Col 43, "No assignment given for field 'A' of type 'Test.R'"
]
[<Fact>]
let ``{...(int -> int)}error`` () =
let src =
"""
type R = { A : int }
let r = { ...(fun x -> x + 1) }
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldFail
|> withSingleDiagnostic (Error 3880, Line 4, Col 31, Line 4, Col 50, "The source expression of a spread into a nominal record expression must have a nominal or anonymous record type.")

Nominal record expression spreads: non-field members on record sources are ignored

module MembersOtherThanRecordFields =
[<Fact>]
let ``Instance properties that are not record fields are ignored`` () =
let src =
"""
type R1 =
{ A : int
B : string }
member this.Lol = string this.A + this.B
type R2 = { ...R1; C : string }
let _ : R2 = { A = 3; B = "3"; C = "asdf" }
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldSucceed
[<Fact>]
let ``All members other than record fields are ignored`` () =
let src =
"""
type R1 =
{ A : int
B : int }
member this.Lol = this.A + this.B
member _.Ha () = ()
static member X = "3"
static member val Y = 42
static member Q () = ()
type R2 = { ...R1; C : string }
let r2 : R2 = { A = 3; B = 3; C = "asdf" }
ignore r2.Lol // Should not exist.
r2.Ha () // Should not exist.
ignore R2.Y // Should not exist.
R2.Q () // Should not exist.
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> typecheck
|> shouldFail
|> withDiagnostics [
Error 39, Line 14, Col 31, Line 14, Col 34, "The type 'R2' does not define the field, constructor or member 'Lol'."
Error 39, Line 15, Col 24, Line 15, Col 26, "The type 'R2' does not define the field, constructor or member 'Ha'."
Error 39, Line 16, Col 31, Line 16, Col 32, "The type 'R2' does not define the field, constructor or member 'Y'."
Error 39, Line 17, Col 24, Line 17, Col 25, "The type 'R2' does not define the field, constructor or member 'Q'."
]

Nominal record expression spreads: effects

module Effects =
[<Fact>]
let ``Effects in spread sources are evaluated exactly once per spread, even if all fields are shadowed`` () =
let src =
"""
type R = { A : int; B : int }
let effects = ResizeArray ()
let f () = effects.Add "f"; { A = 0; B = 1 }
let g () = effects.Add "g"; { A = 2; B = 3 }
let h () = effects.Add "h"; {| A = 99 |}
let r = { ...g (); ...g (); ...h (); A = 100 }
let r' = { f () with ...g (); ...g (); ...h (); A = 100 }
if r.A <> 100 then failwith $"Expected r.A = 100 but got %d{r.A}."
if r'.A <> 100 then failwith $"Expected r'.A = 100 but got %d{r'.A}."
match List.ofSeq effects with
| ["g"; "g"; "h"; "f"; "g"; "g"; "h"] -> ()
| unexpected -> failwith $"Expected [\"g\"; \"g\"; \"h\"; \"f\"; \"g\"; \"g\"; \"h\"] but got %A{unexpected}."
"""
FSharp src
|> withLangVersion SupportedLangVersion
|> compileExeAndRun
|> shouldSucceed

@github-actions
Copy link
Contributor

github-actions bot commented Sep 22, 2025

❗ Release notes required

@brianrourkeboll,

Caution

No release notes found for the changed paths (see table below).

Please make sure to add an entry with an informative description of the change as well as link to this pull request, issue and language suggestion if applicable. Release notes for this repository are based on Keep A Changelog format.

The following format is recommended for this repository:

* <Informative description>. ([PR #XXXXX](https://github.com/dotnet/fsharp/pull/XXXXX))

See examples in the files, listed in the table below or in th full documentation at https://fsharp.github.io/fsharp-compiler-docs/release-notes/About.html.

If you believe that release notes are not necessary for this PR, please add NO_RELEASE_NOTES label to the pull request.

You can open this PR in browser to add release notes: open in github.dev

Change path Release notes path Description
src/Compiler docs/release-notes/.FSharp.Compiler.Service/10.0.200.md No release notes found or release notes format is not correct
LanguageFeatures.fsi docs/release-notes/.Language/preview.md No release notes found or release notes format is not correct

@nojaf
Copy link
Contributor

nojaf commented Sep 22, 2025

Amazing work! I was literally asking @edgarfgp a few days ago if we had something like type R3 = { ...R1; ...R2; E : int } in F#!

@dsyme
Copy link
Contributor

dsyme commented Sep 22, 2025

@brianrourkeboll Great to see work starting in this direction!

Copy link
Contributor Author

@brianrourkeboll brianrourkeboll left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ha, these can go back.

Comment on lines +3713 to +3738
// It seems very likely that there's a better/existing way of doing this.
let spreadSrcTy =
tryAppTy g spreadSrcTy
|> ValueOption.map (fun (tcref, tinst) ->
let tinst =
tinst
|> List.map (fun ty ->
tryDestTyparTy g ty
|> ValueOption.map (fun typar ->
let typars, _, _ = FreshenAndFixupTypars g m TyparRigidity.Flexible [] [] [typar]
mkTyparTy (List.head typars))
|> ValueOption.orElseWith (fun () ->
let tryDestMeasureTy g ty =
match stripTyEqns g ty with
| TType_measure m -> ValueSome m
| _ -> ValueNone

tryDestMeasureTy g ty
|> ValueOption.bind (function Measure.Var typar -> ValueSome typar | _ -> ValueNone)
|> ValueOption.map (fun typar ->
let typars, _, _ = FreshenAndFixupTypars g m TyparRigidity.Flexible [] [] [typar]
TType_measure (Measure.Var (List.head typars))))
|> ValueOption.defaultValue ty)

TType_app (tcref, tinst, g.knownWithoutNull))
|> ValueOption.defaultValue spreadSrcTy
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There must be a better way of freshening up the type parameter here.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cannot this follow what "FreshenMethInfo" does, but on the level of appTy instead?
Doing a match on tryAppTy result, and then FreshenAppTy (a new function added to constraint solver). Working on the original declared typars of the tyconRef (like FreshenTypeInst g range0 (tcref.Typars range0)

I think the measure part is not needed, at least I have not seen it being used elsewhere (the type argument will then be a ttype_var pointing to a measure imo, instead of a direct ttype_measure - so it will get freshened up as well).

After you get freshtypars from FreshenTypeInst .. tcref.Typars ;; SolveTyparsEqualTypes from constraint solver will set the linkage between your new freshened typars and the original tinst.

Comment on lines +890 to +897
| Spread of spread: SynExprSpread * blockSeparator: BlockSeparator option

[<NoEquality; NoComparison>]
type SynExprAnonRecordField = SynExprAnonRecordField of fieldName: SynLongIdent * equalsRange: range option * expr: SynExpr * range: range

[<NoEquality; NoComparison; RequireQualifiedAccess>]
type SynExprAnonRecordFieldOrSpread =
| Field of field: SynExprAnonRecordField * blockSeparator: BlockSeparator option
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you please unify the placement of "blockSeparator" across record expr and anon record expr?
Right now:
field has it at SynExprRecordField
Spread for records has it at SynExprRecordFieldOrSpread
Anons have it at both cases in SynExprAnonRecordFieldOrSpread

If you do that, it might also end up unifying what SynExprAnonRecordField need to be what SynExprRecordField has.

range: range *
trivia: SynMemberDefnAutoPropertyTrivia

| Spread of spread: SynExprSpread * range: range
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this needed?
What use case does it cover now?

(Was it created for experiments with interface spreads etc? )

Comment on lines +925 to +931
|| walkExprs (
List.choose
(function
| SynExprAnonRecordFieldOrSpread.Field(SynExprAnonRecordField(_, _, e, _), _) -> Some e
| SynExprAnonRecordFieldOrSpread.Spread _ -> None (* TODO. *) )
flds
)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
|| walkExprs (
List.choose
(function
| SynExprAnonRecordFieldOrSpread.Field(SynExprAnonRecordField(_, _, e, _), _) -> Some e
| SynExprAnonRecordFieldOrSpread.Spread _ -> None (* TODO. *) )
flds
)
|| walkExprs (
List.map
(function
| SynExprAnonRecordFieldOrSpread.Field(SynExprAnonRecordField(_, _, e, _), _) -> e
| SynExprAnonRecordFieldOrSpread.Spread (expr=e) -> e )
flds
)

SynMatchClause(addOrPat pat, whenExpr, resultExpr, range, debugPoint, trivia)
:: restClauses

let (|SynFields|) (synFieldsAndSpreads: SynFieldOrSpread list) =
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have seen at a few places within collection builders that this was used and did not trigger a warning.

Maybe it should be a lot more explicit (like "FieldsWithoutSpreads"), especially when used at places without exhaustive matching.

| SynExprRecordField((synLongIdent, _), mEquals, Some e, _, _) when orig.IsSome -> Some(synLongIdent, mEquals, e) // copy-and-update, long identifier signifies nesting
| SynExprRecordField((SynLongIdent([ _id ], _, _) as synLongIdent, _), mEquals, Some e, _, _) -> Some(synLongIdent, mEquals, e) // record construction, long identifier not valid
| SynExprRecordField((synLongIdent, _), mEquals, None, _, _) -> Some(synLongIdent, mEquals, arbExpr ("anonField", synLongIdent.Range))
| SynExprRecordFieldOrSpread.Field (SynExprRecordField((synLongIdent, _), mEquals, Some e, m, sep)) when orig.IsSome ->
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would the suggested changes in SyntaxTree lead to making this block (and the one 20 lines below) unnecessary?

Comment on lines +3717 to +3737
let tinst =
tinst
|> List.map (fun ty ->
tryDestTyparTy g ty
|> ValueOption.map (fun typar ->
let typars, _, _ = FreshenAndFixupTypars g m TyparRigidity.Flexible [] [] [typar]
mkTyparTy (List.head typars))
|> ValueOption.orElseWith (fun () ->
let tryDestMeasureTy g ty =
match stripTyEqns g ty with
| TType_measure m -> ValueSome m
| _ -> ValueNone

tryDestMeasureTy g ty
|> ValueOption.bind (function Measure.Var typar -> ValueSome typar | _ -> ValueNone)
|> ValueOption.map (fun typar ->
let typars, _, _ = FreshenAndFixupTypars g m TyparRigidity.Flexible [] [] [typar]
TType_measure (Measure.Var (List.head typars))))
|> ValueOption.defaultValue ty)

TType_app (tcref, tinst, g.knownWithoutNull))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
let tinst =
tinst
|> List.map (fun ty ->
tryDestTyparTy g ty
|> ValueOption.map (fun typar ->
let typars, _, _ = FreshenAndFixupTypars g m TyparRigidity.Flexible [] [] [typar]
mkTyparTy (List.head typars))
|> ValueOption.orElseWith (fun () ->
let tryDestMeasureTy g ty =
match stripTyEqns g ty with
| TType_measure m -> ValueSome m
| _ -> ValueNone
tryDestMeasureTy g ty
|> ValueOption.bind (function Measure.Var typar -> ValueSome typar | _ -> ValueNone)
|> ValueOption.map (fun typar ->
let typars, _, _ = FreshenAndFixupTypars g m TyparRigidity.Flexible [] [] [typar]
TType_measure (Measure.Var (List.head typars))))
|> ValueOption.defaultValue ty)
TType_app (tcref, tinst, g.knownWithoutNull))
let newTints = FreshenTypeInst g range0 (tcref.Typars range0)
SolveTyparsEqualTypes csenv 0 m NoTrace tinst tinst
TType_app (tcref, newTints, g.knownWithoutNull))

This should be the main calls, but of course outside of constraint solver this will need some ceremony for the call

|> List.map (fun fieldPat ->
let (|Last|) = List.last
match fieldPat with
| NamePatPairField (fieldName = SynLongIdent (id = [fieldId]))
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"Last" will cover the single-item list as well.

| SynTypeDefnSimpleRepr.Union(unionCases = unionCases) -> yield! List.collect visitSynUnionCase unionCases
| SynTypeDefnSimpleRepr.Enum(cases = cases) -> yield! List.collect visitSynEnumCase cases
| SynTypeDefnSimpleRepr.Record(recordFields = recordFields) -> yield! List.collect visitSynField recordFields
| SynTypeDefnSimpleRepr.Record(recordFieldsAndSpreads = SynFields recordFields) ->
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This skips spreads and only does fields for content processing.
For spreads, it should visitSynType of the spread to build an edge for graph processing (IIRC what this is for)

| SynTypeDefnSimpleRepr.Union(unionCases = unionCases) -> yield! List.collect visitSynUnionCase unionCases
| SynTypeDefnSimpleRepr.Enum(cases = cases) -> yield! List.collect visitSynEnumCase cases
| SynTypeDefnSimpleRepr.Record(recordFields = recordFields) -> yield! List.collect visitSynField recordFields
| SynTypeDefnSimpleRepr.Record(recordFieldsAndSpreads = SynFields recordFields) ->
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Also here, SynFields AP drops spreads.
This is for sig files (do spreads have any special treatment in sig files?)

| Some contextLid, SynExprRecordField(fieldName = lid, _) :: _ -> contextLid.Range = lid.Range
| Some contextLid, SynExprRecordFieldOrSpread.Field(SynExprRecordField(fieldName = lid, _)) :: _ ->
contextLid.Range = lid.Range
// TODO: spreads.
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

spreads should have "false" for firstField, it is used to affect completion before type checker knows target type of the record creation expression.

Spreads should perhaps have a new RecordContext.Spread completion context ?
Existing instances of records and anons in scope could be offered first.


/// Helper used to check record expressions and record patterns
let BuildFieldMap (cenv: cenv) env isPartial ty (flds: ((Ident list * Ident) * 'T) list) m =
let BuildFieldMap (cenv: cenv) env isPartial ty (flds: ExplicitOrSpread<(Ident list * Ident) * 'Explicit, (Ident list * Ident) * 'Spread> list) m =
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This file has grown by a lot and pieces of the logic are fairly spread specific.
Can you imagine extracting the biggest blocks into a separate file, with passing in mutually resursive functions as a callback (like TcExprFlex) ?

let spreadSrcExpr, tpenv = TcExprFlex cenv flex false (NewInferenceType g) env tpenv expr
let tyOfSpreadSrcExpr = tyOfExpr g spreadSrcExpr

if isRecdTy g tyOfSpreadSrcExpr || isAnonRecdTy g tyOfSpreadSrcExpr then
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In case of g.checkNullness, it should only do so for nun-nullable record expressions (nullnessOfTy function)


let flex = false
let spreadSrcExpr, _ = TcExprFlex cenv flex false (NewInferenceType g) env tpenv expr
let tyOfSpreadSrcExpr = tyOfExpr g spreadSrcExpr
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same here - should reject nullable tyOfSpreadSrcExpr if it is a withNull recdTy

// ----------------------------------------------------------------------------------
// ----------------------------------------------------------------------------------
// TODO: Collect spread src exprs so we don't re-typecheck them?
// TODO: It would be better if we could _try_ to typecheck fields and undo if they
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a scenario, where this causes troubles?
Even in case of duplicates, they get a new inference type variable allocated during type checking, I did not find a source of possible clashes in type inference.

| Item.RecdField fieldInfo :: fieldsFromSpread ->
let fieldExpr = mkRecdFieldGetViaExprAddr (spreadSrcAddrExpr, fieldInfo.RecdFieldRef, fieldInfo.TypeInst, m)
let fieldId = fieldInfo.RecdFieldRef.RecdField.Id
ignore dedupedSortedFieldTys
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

?

[<NoEquality; NoComparison; DefaultAugmentation(false)>]
type R3 = { ...R1; ...R2 }
[<NoEquality; NoComparison; DefaultAugmentation(false)>]
type R4 = { ...R2; ...R1 }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I am not sure if the RFC or discussion covered it, but it just occurred to me that by "flattening" the fields of srcTy into tgTy, this fully bypasses static init.

Both static do / static let at the srcTy, or any module-level logic.

I cannot imagine any normal code in need of this, but it should get a mention in the design and being called out as one of the few symbol usages that do not trigger static init.

@@ -0,0 +1,71 @@
module EmittedIL.NominalRecordExpressionSpreads
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe I missed it, but I did not find IL tests with structness, most relevant at expression spreads with anons and regular records.
(also a 1:1 spread for structifying an existing anon).

Can you please add some?

/// let r = { A = 3; ...b; C = true }
[<NoEquality; NoComparison; RequireQualifiedAccess>]
type SynExprRecordFieldOrSpread =
| Field of field: SynExprRecordField
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can the field here ever have expr=None?

Copy link
Member

@T-Gro T-Gro left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is excellent work @brianrourkeboll .
I like the level of detail in the PR's description and the structure of tests reading like a specification, this really helps understanding the topic.

The feature is well designed and covers a lot of edge cases, my reviewer comments are rather minor and I am not strongly attached to any of them.

After thinking about a summary, those points stand out in no particular order:

  1. Spreads in signature files, Yes/no - in either case, positive/negative tests should demonstrate what happens.

  2. Affect on graph type checking might be missing due to the ActivePattern usage which drops spreads. In general I would be more careful OR a lot more explicit in saying that AP drops info.

  3. I believe that SyntaxTree is very close in unifying representations for key data structures around records and anons, and doing that will also simplify mappings done in the parser and then also allow extracting helpers for CheckExpressions. I am not sure if this is @edgarfgp was planning earlier - this can definitely go in separately if Edgar has a plan already

  4. I would like to see IL + "run" test for combinations of structness involved.

  5. It is clear from the implementation, but maybe not immediately clear from an un-involved user's perspective. Type spreading bypasses static init (e.g. static do, static let - also in generic contexts) and module-level init of the srcTy. It should deserve a spec update by listing it as a special case at https://github.com/fsharp/fslang-spec/blob/716386d9ab611f631e2cf42ba742de4e444beb11/spec/program-structure-and-execution.md#execution-of-static-initializers

  6. A lot of code was added to CheckExpressions and much of the logic are clear business rules related to spreads. It would be great if we can think on what would be needed to extract the biggest blocks into a dedicated file.
    (especially when foreseeing further growth)

Comment on lines +737 to +741
run
f
xs
(SynExprRecordFieldOrSpread.Field(SynExprRecordField(name, mEquals, value, fieldRange, m))
:: acc)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
run
f
xs
(SynExprRecordFieldOrSpread.Field(SynExprRecordField(name, mEquals, value, fieldRange, m))
:: acc)
let field = SynExprRecordFieldOrSpread.Field(SynExprRecordField(name, mEquals, value, fieldRange, m)
run f xs (field :: acc)

This was likely done by Fantomas, but it does not read very well.

Copy link
Contributor

@Martin521 Martin521 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is really great to see. Thanks!
But please, please update the RFC before merging this one.
I don't think it needs much text, perhaps even just the test cases from the PR post.
And if possible the related spec changes.

@github-project-automation github-project-automation bot moved this from New to In Progress in F# Compiler and Tooling Dec 15, 2025
@brianrourkeboll
Copy link
Contributor Author

This is really great to see. Thanks! But please, please update the RFC before merging this one. I don't think it needs much text, perhaps even just the test cases from the PR post. And if possible the related spec changes.

@Martin521 I agree.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

6 participants