F# Module 4 – Modelling data with types
Tuples, records and anonymous records
Group F# values in tuples and struct tuples, name them with records and nested copy-and-update, and choose anonymous, struct or CLIMutable records.
What you will learn
- Group values in tuples and struct tuples and take them apart with patterns
- Define records, build them and copy them with nested copy-and-update
- Build a projection with an anonymous record and keep it out of public APIs
- Choose [<Struct>] records for large arrays of small values and [<CLIMutable>] for serializers
Before you start
What you’ll learn · Before you start
What you will learn
- Group values in tuples and struct tuples and take them apart with patterns
- Define records, build them and copy them with nested copy-and-update
- Build a projection with an anonymous record and keep it out of public APIs
- Choose [<Struct>] records for large arrays of small values and [<CLIMutable>] for serializers
Before you start
On this page
("Pune", 1200, 3) is a sale: a city, a total of 1200 rupees and a count of 3 items. Nothing in the value says which
number is which, so a caller who writes let (city, items, total) = sale gets the total in items, and the compiler
accepts it, because both are int. Give the parts names and the compiler checks every use.
You will group values in tuples and struct tuples, define records and copy them with a field changed, nested fields
included, shape a quick result with an anonymous record, and decide when [<Struct>] and [<CLIMutable>] earn their
place. Every rule comes with a run recorded on .NET 10, from the error messages to the bytes a million records take.
A tuple groups values by position, so keep it close to where it is made
A tuple: A fixed group of values in order, possibly of different types, such as ("Pune", 1200) of type string * int. Its parts have positions but no names, and patterns take them apart.
is the quickest way to hand back two or three values at once. Its type lists the types of its parts, string * int * int for the sale, and a pattern takes it apart: describe below declares its parameter as a pattern of three names.
The session also tries what a tuple cannot do:
from session import fsi
# A tuple of three values taken apart by a pattern, fst on a triple, then a reference tuple and a struct tuple: their
# .NET types, the pattern that mixes them up, the Item1 a C# habit reaches for, fst on a struct pair, and equality by
# value.
fsi([
'let sale = ("Pune", 1200, 3);;',
'let describe (city: string, total: int, items: int) = $"{city}: {items} items, {total} rupees";;',
'describe sale;;',
'fst sale;;',
'let pair = ("Pune", 1200);;',
'fst pair, snd pair;;',
'let point = struct (3, 4);;',
'pair.GetType().Name, point.GetType().Name;;',
'let (x, y) = point;;',
'let struct (x, y) = point;;',
'point.Item1;;',
'fst point;;',
'("Pune", 1200) = ("Pune", 1200);;',
'#quit;;',
], args=["--nologo"]) Output
$ dotnet fsi --nologo
> let sale = ("Pune", 1200, 3);;
val sale: string * int * int = ("Pune", 1200, 3)
> let describe (city: string, total: int, items: int) = $"{city}: {items} items, {total} rupees";;
val describe: city: string * total: int * items: int -> string
> describe sale;;
val it: string = "Pune: 3 items, 1200 rupees"
> fst sale;;
fst sale;;
----^^^^
stdin(4,5): error FS0001: Type mismatch. Expecting a tuple of length 2 of type
'a * 'b
but given a tuple of length 3 of type
string * int * int
> let pair = ("Pune", 1200);;
val pair: string * int = ("Pune", 1200)
> fst pair, snd pair;;
val it: string * int = ("Pune", 1200)
> let point = struct (3, 4);;
val point: struct (int * int) = struct (3, 4)
> pair.GetType().Name, point.GetType().Name;;
val it: string * string = ("Tuple`2", "ValueTuple`2")
> let (x, y) = point;;
let (x, y) = point;;
-------------^^^^^
stdin(9,14): error FS0001: One tuple type is a struct tuple, the other is a reference tuple
> let struct (x, y) = point;;
val y: int = 4
val x: int = 3
> point.Item1;;
point.Item1;;
------^^^^^
stdin(11,7): error FS0039: The field, constructor or member 'Item1' is not defined.
> fst point;;
fst point;;
----^^^^^
stdin(12,5): error FS0001: One tuple type is a struct tuple, the other is a reference tuple
> ("Pune", 1200) = ("Pune", 1200);;
val it: bool = true
> #quit;;
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 tuples_session.py
describe sale worked because the pattern (city, total, items) matched the three positions in order. The names
belong to the pattern, not to the tuple, so a pattern that lists them in another order binds the wrong values without
a word. fst and snd take pairs only: fst sale on a triple was error FS0001, a tuple of length 2 expected. The
tuples reference recommends patterns for reading the parts of any tuple.
A tuple is a reference type by default: the pair’s type was Tuple`2, a System.Tuple, which is an object of its
own. Write struct in front and it becomes a struct tuple: A tuple written struct (a, b). It compiles to System.ValueTuple, a value type with no object of its own, which is also the type of a C# tuple.,
ValueTuple`2, which is a value type: A .NET type whose values live where they are stored, in a variable, a field or an array element, and are copied on assignment, such as int, a struct or a struct record. and the same type as a C# tuple. The tuples
reference says why that matters: C# tuples are structs, so F# code that passes tuples to C# methods or reads theirs
must use struct tuples. The two forms never convert by themselves:
| Feature | Reference tuple | Struct tuple |
|---|---|---|
| Written | ("Pune", 1200) |
struct (3, 4) |
| .NET type | System.Tuple |
System.ValueTuple |
| Taken apart by | let (a, b) = pair |
let struct (x, y) = point |
fst and snd |
pairs only | not at all: FS0001 |
| C# tuples | no | the same type |
let (x, y) = point was error FS0001, one tuple type is a struct tuple and the other a reference tuple, and fst point
failed with the same message. point.Item1, the C# way to read the first part, was FS0039: F# takes tuples apart with
patterns, not with properties.
Letting a tuple travel
Wrong: one function returns (total, items) and another, three files away, reads it as let (items, total) = ….
Both parts are int, so nothing fails until the report shows 1200 items. Right: keep a tuple inside the function
or module that makes it, and give anything that travels named fields, which the next section does.
Checkpoint
Check: Tuples and struct tuples
A record names its fields and compares them by value
A record: An F# type with named fields that is immutable and compared by value by default. { r with Field = v } makes a copy with one field changed and leaves r as it was. More in “What F# is and where it fits in .NET” type names every field: type Address = { City: string; Pin: string }. A record
expression gives each field a value, with the labels in any order, and the compiler infers the type from the labels.
records.fsx builds three addresses, compares them and matches one:
// A record type and record expressions: labels in any order, equality and ordering by value, a record pattern.
type Address = { City: string; Pin: string }
let home = { City = "Pune"; Pin = "411001" }
let sameHome = { City = "Pune"; Pin = "411001" }
let office = { Pin = "411014"; City = "Pune" }
printfn "%A" home
printfn "home = sameHome: %b" (home = sameHome)
printfn "same object: %b" (obj.ReferenceEquals(home, sameHome))
printfn "compare home office: %d" (compare home office)
let addresses = [ office; { City = "Agra"; Pin = "282001" }; home ]
for a in List.sort addresses do
printfn "sorted: %s %s" a.City a.Pin
let where address =
match address with
| { City = "Pune" } -> "in Pune"
| { City = city } -> $"outside Pune, in {city}"
printfn "%s; %s" (where home) (where addresses[1]) from session import fsi, run
# records.fsx, then two record types with the same labels: which type a record expression gets, with and without a
# type the compiler already expects, and an expression that leaves a field out.
run("""
dotnet fsi records.fsx
""")
fsi([
'type Shipment = { Id: int; City: string };;',
'type Store = { Id: int; City: string };;',
'let ship (x: Shipment) = $"shipping {x.Id} to {x.City}";;',
'let s = { Id = 7; City = "Pune" };;',
'ship s;;',
'ship { Id = 7; City = "Pune" };;',
'let s2 = { Shipment.Id = 7; City = "Pune" };;',
'let s3 = { Shipment.Id = 8 };;',
'#quit;;',
], args=["--nologo"]) Output
$ dotnet fsi records.fsx
{ City = "Pune"
Pin = "411001" }
home = sameHome: true
same object: false
compare home office: -1
sorted: Agra 282001
sorted: Pune 411001
sorted: Pune 411014
in Pune; outside Pune, in Agra
$ dotnet fsi --nologo
> type Shipment = { Id: int; City: string };;
type Shipment =
{
Id: int
City: string
}
> type Store = { Id: int; City: string };;
type Store =
{
Id: int
City: string
}
> let ship (x: Shipment) = $"shipping {x.Id} to {x.City}";;
val ship: x: Shipment -> string
> let s = { Id = 7; City = "Pune" };;
val s: Store = { Id = 7
City = "Pune" }
> ship s;;
ship s;;
-----^
stdin(5,6): error FS0001: This expression was expected to have type
'Shipment'
but here has type
'Store'
> ship { Id = 7; City = "Pune" };;
val it: string = "shipping 7 to Pune"
> let s2 = { Shipment.Id = 7; City = "Pune" };;
val s2: Shipment = { Id = 7
City = "Pune" }
> let s3 = { Shipment.Id = 8 };;
let s3 = { Shipment.Id = 8 };;
---------^^^^^^^^^^^^^^^^^^^
stdin(8,10): error FS0764: No assignment given for field 'City' of type 'FSI_0002.Shipment'
> #quit;;
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 records_session.py
home and sameHome are two objects, same object: false, and still home = sameHome is true. Records use
structural equality: Comparing two values by their contents: two records are equal when every field is equal, even if they are separate objects. F# records, unions and tuples compare this way by default.:
the records reference says they have the structural equality of unions and structs, while classes compare references.
They are ordered field by field as well, in the order the fields are declared. compare home office gave -1, because
the cities are equal and “411001” comes before “411014”, and List.sort put Agra first. A record pattern such as
{ City = "Pune" } tests only the fields it names; the pattern-matching lesson of this module takes that further.
The second half of the session shows the one trap of inferring a type from labels. Shipment and Store were both
declared with Id and City. Step down what the compiler did with each expression:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Labels, no type name. A record expression names its labels, not its type. In this session both Shipment and Store were declared with the labels Id and City, so the labels alone cannot decide.
- An expected type decides. In ship { Id = 7; City = "Pune" } the compiler already knows that ship takes a Shipment, so the expression is built as one. The call returned "shipping 7 to Pune".
- Nothing expected. let s = { Id = 7; City = "Pune" } expects no type, so the compiler looks the labels up in the record types in scope. Shipment and Store both have Id and City.
- The last declared wins. The labels of the most recently declared type take precedence, so F# Interactive answered val s: Store. Nothing warns about the choice.
- The error comes later. ship s then fails with FS0001: expected Shipment, but here has type Store. Naming the type once, { Shipment.Id = 7; City = "Pune" }, gave val s2: Shipment.
An expected type decides first, and only then the labels, where the type declared last wins. A record expression must
also give every field: { Shipment.Id = 8 } was error FS0764, no assignment given for field City.
Name the type where labels are shared
When two record types share every label, write the type once in the expression, { Shipment.Id = 7; City = "Pune" },
or give each type its own field names. The error otherwise appears far from the line that caused it.
Checkpoint
Check: Records, labels and equality
Copy-and-update makes a new record and shares what did not change
Records cannot be changed, so moving Asha to Nashik means building a new customer. A
copy-and-update expression: A record expression such as { c with City = "Nashik" } that builds a new record from c with the fields it lists replaced. The record c itself is not changed.,
{ asha with … }, copies every field it is not told to change. Since F# 8 it reaches into a nested record with dot
notation, Home.City, where older code nests one with inside another; copy.fsx writes the update both ways:
// Nested copy-and-update (F# 8): a new customer with a new home; the old value is unchanged, and what did not change
// is shared, not copied.
type Address = { City: string; Pin: string }
type Customer = { Name: string; Home: Address; Office: Address }
let asha =
{ Name = "Asha"
Home = { City = "Pune"; Pin = "411001" }
Office = { City = "Pune"; Pin = "411014" } }
let moved = { asha with Home.City = "Nashik"; Home.Pin = "422001" }
printfn "asha.Home: %s %s" asha.Home.City asha.Home.Pin
printfn "moved.Home: %s %s" moved.Home.City moved.Home.Pin
printfn "same Home object: %b" (obj.ReferenceEquals(asha.Home, moved.Home))
printfn "same Office object: %b" (obj.ReferenceEquals(asha.Office, moved.Office))
// The same update written before F# 8, one with per level.
let movedOld = { asha with Home = { asha.Home with City = "Nashik"; Pin = "422001" } }
printfn "moved = movedOld: %b" (moved = movedOld) from session import fsi, run
# copy.fsx, then a field named like its own type (Address: Address), where the nested update needs the record's type
# in front.
run("""
dotnet fsi copy.fsx
""")
fsi([
'type Address = { City: string; Pin: string };;',
'type Person = { Name: string; Address: Address };;',
'let ravi = { Name = "Ravi"; Address = { City = "Agra"; Pin = "282001" } };;',
'let r2 = { ravi with Address.City = "Kanpur" };;',
'let r3 = { ravi with Person.Address.City = "Kanpur" };;',
'#quit;;',
], args=["--nologo"]) Output
$ dotnet fsi copy.fsx
asha.Home: Pune 411001
moved.Home: Nashik 422001
same Home object: false
same Office object: true
moved = movedOld: true
$ dotnet fsi --nologo
> type Address = { City: string; Pin: string };;
type Address =
{
City: string
Pin: string
}
> type Person = { Name: string; Address: Address };;
type Person =
{
Name: string
Address: Address
}
> let ravi = { Name = "Ravi"; Address = { City = "Agra"; Pin = "282001" } };;
val ravi: Person = { Name = "Ravi"
Address = { City = "Agra"
Pin = "282001" } }
> let r2 = { ravi with Address.City = "Kanpur" };;
let r2 = { ravi with Address.City = "Kanpur" };;
---------^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
stdin(4,10): error FS0001: This expression was expected to have type
'Person'
but here has type
'Address'
> let r3 = { ravi with Person.Address.City = "Kanpur" };;
val r3: Person = { Name = "Ravi"
Address = { City = "Kanpur"
Pin = "282001" } }
> #quit;;
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 copy_session.py
asha kept Pune and 411001, moved has Nashik and 422001, and moved = movedOld is true: the two forms build equal
records. The two ReferenceEquals lines show how the copy was made. Step through it:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Three records. asha is a Customer whose Home and Office fields each hold an Address record of their own. Each record is a separate object on the heap.
- A new Home first. The update names Home.City and Home.Pin, so it first builds a new Address: a copy of asha.Home with both fields replaced, Nashik and 422001.
- Then a new Customer. Then it builds a new Customer, moved, around the new Home. Every field the update does not name is copied as it is, and for Office that copy is a reference to the same record.
- The old value stays. Nothing was written into asha: after the update, copy.fsx still printed asha.Home: Pune 411001 next to moved.Home: Nashik 422001.
- Shared, and safe to share. obj.ReferenceEquals tells the two apart: same Home object false, same Office object true. The copy is cheap because it shares what did not change, and sharing is safe because no one can change a record.
The new customer got a new Home, while Office is the very same object in both: a copy-and-update builds new records
only along the path it changes and shares the rest. That is cheap, and safe, because nothing can change a record that
two values share.
A field named like its own type
Wrong: { ravi with Address.City = "Kanpur" } where Person has the field Address: Address. It was error FS0001:
the compiler read Address.City as the field City of the type Address, the older way of naming a field’s type,
which the F# 8 announcement says still takes priority. Right: { ravi with Person.Address.City = "Kanpur" }.
Key idea
Copy-and-update never changes the record it copies: it builds new records along the path it updates, and the new value shares every record it did not touch with the old one.
Checkpoint
Check: Copy-and-update
Two attributes change how a record is stored and built
Records are reference types unless you say otherwise. [<Struct>] makes one a value type, stored inline in an array or
a field, and [<CLIMutable>] adds what some .NET libraries expect of a class. layout.fsx measures the first with
GC.GetAllocatedBytesForCurrentThread, keeping the smallest of five measurements because a single one now and then
counts a few bytes more, and tries the second on two serializers:
// What [<Struct>] changes in memory, measured, and what [<CLIMutable>] adds for a serializer that builds an object
// first and sets its properties afterwards.
open System
open System.IO
open System.Text.Json
open System.Xml.Serialization
[<Struct>]
type PointS = { X: float; Y: float }
type PointR = { X: float; Y: float }
// The smallest of five measurements: now and then a single measurement counts a few bytes more than the array and
// its points take.
let bytesAllocatedBy make =
List.min
[ for _ in 1..5 ->
let before = GC.GetAllocatedBytesForCurrentThread()
make () |> ignore
GC.GetAllocatedBytesForCurrentThread() - before ]
printfn "sizeof<PointS> = %d, sizeof<PointR> = %d" sizeof<PointS> sizeof<PointR>
printfn "1,000,000 struct records: %d bytes" (bytesAllocatedBy (fun () -> Array.init 1_000_000 (fun i -> { PointS.X = float i; Y = 0.0 })))
printfn "1,000,000 reference records: %d bytes" (bytesAllocatedBy (fun () -> Array.init 1_000_000 (fun i -> { PointR.X = float i; Y = 0.0 })))
type Row = { Name: string; Total: decimal }
[<CLIMutable>]
type RowM = { Name: string; Total: decimal }
let hasEmptyConstructor (t: Type) = not (isNull (t.GetConstructor Type.EmptyTypes))
printfn "parameterless constructor: Row %b, RowM %b" (hasEmptyConstructor typeof<Row>) (hasEmptyConstructor typeof<RowM>)
printfn "Total can be set: Row %b, RowM %b" (typeof<Row>.GetProperty("Total").CanWrite) (typeof<RowM>.GetProperty("Total").CanWrite)
try
XmlSerializer(typeof<Row>) |> ignore
with e ->
printfn "XmlSerializer and Row: %s" e.Message
let xml = "<RowM><Name>Asha</Name><Total>1200</Total></RowM>"
let row = XmlSerializer(typeof<RowM>).Deserialize(new StringReader(xml)) :?> RowM
printfn "XmlSerializer and RowM: %s, %M" row.Name row.Total
let fromJson = JsonSerializer.Deserialize<Row>("""{"Name":"Asha","Total":1200}""")
printfn "System.Text.Json and Row: %s, %M" fromJson.Name fromJson.Total from session import fsi, run
# layout.fsx, then what F# code may do with a [<CLIMutable>] record: build it as usual, but never assign a field.
run("""
dotnet fsi layout.fsx
""")
fsi([
'[<CLIMutable>] type RowM = { Name: string; Total: decimal };;',
'let r = { Name = "Asha"; Total = 1200m };;',
'r.Total <- 0m;;',
'#quit;;',
], args=["--nologo"]) Output
$ dotnet fsi layout.fsx
sizeof<PointS> = 16, sizeof<PointR> = 8
1,000,000 struct records: 16000024 bytes
1,000,000 reference records: 40000024 bytes
parameterless constructor: Row false, RowM true
Total can be set: Row false, RowM true
XmlSerializer and Row: FSI_0001.Row cannot be serialized because it does not have a parameterless constructor.
XmlSerializer and RowM: Asha, 1200
System.Text.Json and Row: Asha, 1200
$ dotnet fsi --nologo
> [<CLIMutable>] type RowM = { Name: string; Total: decimal };;
type RowM =
{
Name: string
Total: decimal
}
> let r = { Name = "Asha"; Total = 1200m };;
val r: RowM = { Name = "Asha"
Total = 1200M }
> r.Total <- 0m;;
r.Total <- 0m;;
--^^^^^
stdin(3,3): error FS0005: This field is not mutable
> #quit;;
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 layout_session.py
sizeof gave 16 bytes for PointS, its two floats, and 8 for PointR, which is only a reference. A million struct
points took 16,000,024 bytes: one array holding the points themselves. A million reference points took 40,000,024:
an array of 8-byte references, 8,000,024 bytes with the same 24 bytes of overhead, plus a separate 32-byte object for
each point. That is 2.5 times the
memory, and a million more objects for the garbage collector to track. The .NET design guidelines still advise a
struct only for small, immutable values of under 16 bytes that are rarely boxed, because a struct is copied whole on
every assignment and every call; PointS sits right at that limit. The attribute pays in large arrays of small
values, and seldom anywhere else.
[<CLIMutable>], says its FSharp.Core page, compiles the record with a parameterless constructor and property setters.
F# code is unchanged: r.Total <- 0m was still error FS0005, this field is not mutable. The setters are for code that
builds an empty object first and fills it in afterwards, as XmlSerializer does:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- An element to fill. XmlSerializer reads the element RowM. It does not call the record's constructor with the two values; it needs an empty object to put the children into.
- The empty object. It calls the parameterless constructor that [<CLIMutable>] added. The plain Row has none, and the serializer refused it: Row cannot be serialized because it does not have a parameterless constructor.
- One setter per field. It sets each property from its element, starting with Name. The setter is there for other .NET code only: in F#, r.Total <- 0m was error FS0005, this field is not mutable.
- The second field. Total is set from its element the same way, converted from the text 1200 to a decimal.
- An ordinary record again. layout.fsx printed XmlSerializer and RowM: Asha, 1200. To F# code the value is a record like any other, with equality and copy-and-update.
System.Text.Json read the plain Row without the attribute, because for a class whose only constructor takes
parameters it calls that constructor, as its documentation on immutable types explains. Add [<CLIMutable>] when a
library refuses a record, not to every record by habit.
Checkpoint
Check: Struct and CLIMutable records
Anonymous records shape a result without declaring a type
A sales report grouped by city needs a row with a city, a count and a total, and nothing else will ever use that row. An anonymous record: A record written {| Name = "Asha" |} whose type the compiler makes from its field names and types, with no declaration. Two values with the same fields have the same type. gives it named fields without a declaration:
// Anonymous records for a projection: sales grouped by city, then a field added with copy-and-update.
open System
type Sale = { City: string; Amount: decimal }
let sales =
[ { City = "Pune"; Amount = 1200m }
{ City = "Agra"; Amount = 450m }
{ City = "Pune"; Amount = 300m } ]
let byCity =
sales
|> List.groupBy _.City
|> List.map (fun (city, rows) -> {| City = city; Orders = rows.Length; Total = List.sumBy _.Amount rows |})
for c in byCity do
printfn "%s: %M rupees, orders: %d" c.City c.Total c.Orders
let grand = byCity |> List.sumBy _.Total
let withShare = byCity |> List.map (fun c -> {| c with Share = Math.Round(c.Total / grand, 3) |})
printfn "%A" withShare.Head from session import fsi, run
# anonymous.fsx, then what anonymous records refuse: a value with an extra field and a pattern, and a field read
# before the compiler knows the type.
run("""
dotnet fsi anonymous.fsx
""")
fsi([
'let a = {| Name = "Asha"; Total = 1200 |};;',
'a = {| Total = 1200; Name = "Asha" |};;',
'let total (x: {| Name: string; Total: int |}) = x.Total;;',
'total {| a with City = "Pune" |};;',
'match a with {| Name = n |} -> n;;',
'List.sumBy _.Total [ a ];;',
'List.sumBy (fun r -> r.Total) [ a ];;',
'[ a ] |> List.sumBy _.Total;;',
'#quit;;',
], args=["--nologo"]) Output
$ dotnet fsi anonymous.fsx
Pune: 1500 rupees, orders: 2
Agra: 450 rupees, orders: 1
{| City = "Pune"
Orders = 2
Share = 0.769M
Total = 1500M |}
$ dotnet fsi --nologo
> let a = {| Name = "Asha"; Total = 1200 |};;
val a: {| Name: string; Total: int |} = {| Name = "Asha"
Total = 1200 |}
> a = {| Total = 1200; Name = "Asha" |};;
val it: bool = true
> let total (x: {| Name: string; Total: int |}) = x.Total;;
val total: x: {| Name: string; Total: int |} -> int
> total {| a with City = "Pune" |};;
total {| a with City = "Pune" |};;
------^^^^^^^^^^^^^^^^^^^^^^^^^^
stdin(4,7): error FS0001: This anonymous record has an extra field. Remove field 'City'.
> match a with {| Name = n |} -> n;;
match a with {| Name = n |} -> n;;
-------------^^
stdin(5,14): error FS0010: Unexpected symbol '{|' in expression
> List.sumBy _.Total [ a ];;
List.sumBy _.Total [ a ];;
-----------^^^^^^^
stdin(6,12): error FS0072: The type of this expression could not be inferred before accessing its members. Add a type annotation, e.g. (expr: SomeType), to constrain the type before this point.
> List.sumBy (fun r -> r.Total) [ a ];;
List.sumBy (fun r -> r.Total) [ a ];;
---------------------^^^^^^^
stdin(7,22): error FS0072: The type of this expression could not be inferred before accessing its members. Add a type annotation, e.g. (expr: SomeType), to constrain the type before this point.
> [ a ] |> List.sumBy _.Total;;
val it: int = 1200
> #quit;;
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 anonymous_session.py
{| City = city; Orders = …; Total = … |} made one row per city, Pune with 1500 rupees from two orders, Agra with 450.
The type is the set of field names and types, whatever their order: a = {| Total = 1200; Name = "Asha" |} was true.
Copy-and-update may even add a field, as {| c with Share = … |} did, which a named record cannot do.
The session shows the limits. A function that takes {| Name: string; Total: int |} refused the same value with an
extra City, error FS0001, because an anonymous record matches exact fields only. Patterns are not supported, FS0010,
which the anonymous records reference explains by that exactness. And List.sumBy _.Total [ a ] was FS0072, with a
lambda as well. anonymous.fsx wrote List.sumBy _.Amount rows without trouble, because Amount is the label of the
declared record Sale, which the compiler can look up; a field of an anonymous record is found only once the type is
known, so put the list first, [ a ] |> List.sumBy _.Total.
Those limits decide where anonymous records belong: inside one module, for projections and quick results. A public function that returns one fixes its exact shape, so adding a field later changes the type that every caller sees.
Checkpoint
Check: Anonymous records
Choose the shape by who reads the value
Each shape in this lesson is right somewhere. What decides is who reads the value, how many parts it has and how many copies of it a program keeps. Answer the three questions in the table for a value of your own:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Shape | Named fields | Across modules | Patterns | A million of them |
|---|---|---|---|---|
| Tuple Written: ("Pune", 1200) | Poor positions only; fst sale on a triple was FS0001 | Mixed fine for two unrelated values, such as a quotient and a remainder | Good let (city, total) = … and tupled parameters | Mixed each one a System.Tuple object on the heap |
| Struct tuple Written: struct (3, 4) | Poor positions only; fst point was FS0001 | Mixed the same type as a C# value tuple | Good let struct (x, y) = point | Good a System.ValueTuple: no object of its own |
| Record Written: { City = "Pune"; Pin = … } | Good named fields, checked when the value is built | Good a declared type every caller can name | Good record patterns such as { City = "Pune" } | Mixed 40,000,024 bytes for a million two-float points |
| Struct record Written: [<Struct>] type PointS | Good named fields, as any record | Good a declared type, copied whole when passed | Good record patterns, as any record | Good 16,000,024 bytes for the same million points |
| Anonymous record Written: {| City = "Pune"; … |} | Good named fields without a declaration | Poor an extra field made a different type: FS0001 | Poor no patterns: match a with {| … |} was FS0010 | Mixed an object each, like a reference record |
How the answers decide
- “Does the value leave the function or module that makes it?” Yes, other code reads it: rules out Anonymous record and adds a caveat to Tuple and Struct tuple. Code that reads the value elsewhere needs names it can rely on. Adding one field to an anonymous record made a new type, and a tuple's positions carry no names at all.
- “How many values does it group?” Three or more, or related ones: adds a caveat to Tuple and Struct tuple. Three positions are easy to swap in a pattern, and fst and snd stop at two. Named fields say which value is which.
- “Will an array hold millions of them?” Yes, millions: adds a caveat to Tuple, Record and Anonymous record. Each of these is an object of its own: a million two-float reference records took 40,000,024 bytes against 16,000,024 as struct records.
- “Will an array hold millions of them?” No, a handful at a time: adds a caveat to Struct record. A struct is copied whole on every assignment and call. With a handful of values it saves nothing worth the attribute.
In short: a tuple for two unrelated values that stay local, such as the quotient and remainder that the F# component
design guidelines give as their example; a record for anything that crosses a boundary or has three parts or more; an
anonymous record for a projection that stays inside one module; and [<Struct>] only when millions of small values sit
in arrays.
Checkpoint
Check: Choosing a shape
Practise: move a customer and summarise them
practice.fsx combines the lesson: a nested copy-and-update moves a customer to a new delivery address, an anonymous record summarises them, and the last lines check that the old value did not change:
// Practise: move a customer to a new delivery address with nested copy-and-update, then summarise the customer as an
// anonymous record. Change moveCustomer or summary and run the script again.
type Address = { Line: string; City: string; Pin: string }
type Customer = { Name: string; Delivery: Address }
let moveCustomer newCity newPin customer =
{ customer with Delivery.City = newCity; Delivery.Pin = newPin }
let summary customer =
{| Name = customer.Name; DeliverTo = $"{customer.Delivery.City} {customer.Delivery.Pin}" |}
let meera = { Name = "Meera"; Delivery = { Line = "12 Station Road"; City = "Pune"; Pin = "411001" } }
let moved = moveCustomer "Nashik" "422001" meera
printfn "%A" (summary meera)
printfn "%A" (summary moved)
printfn "meera still in Pune: %b" (meera.Delivery.City = "Pune")
printfn "line kept: %s" moved.Delivery.Line
printfn "rebuilt equals moved: %b" (moved = { Name = "Meera"; Delivery = { Line = "12 Station Road"; City = "Nashik"; Pin = "422001" } }) from session import run
run("""
dotnet fsi practice.fsx
""") Output
$ dotnet fsi practice.fsx
{| DeliverTo = "Pune 411001"
Name = "Meera" |}
{| DeliverTo = "Nashik 422001"
Name = "Meera" |}
meera still in Pune: true
line kept: 12 Station Road
rebuilt equals moved: true
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 practice_session.py
meera is still in Pune, the new address kept its Line, and a record rebuilt by hand equals moved, field by field.
Add a field to the summary
Add Line = customer.Delivery.Line to the anonymous record in summary and run dotnet fsi practice.fsx again: both
summaries print three fields. Then add Phone: string to Customer: the script stops with FS0764, no assignment
given for field Phone, at the first record expression that builds a customer, until every such expression gives it
a value.
Then test yourself on the six ideas of this lesson. The next question always comes from the idea you have shown least so far, and every answer you pick is explained, the wrong ones included:
Practice that adapts to you
Practise this lesson
12 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to 2–5 years): when do you use a tuple and when a record in F#?
A tuple groups values by position, so it suits two values that stay close to where they are made, such as a function
that returns a quotient and a remainder to its caller in the same module. Its parts have no names: a pattern that lists
them in the wrong order compiles whenever the types agree, and fst and snd work on pairs only. A record names each
field, checks every field when the value is built, compares by value and can be matched by field, so it is the choice
for anything with three parts or more, anything other modules read and anything stored or serialized. Use a struct
tuple when a C# method takes or returns a tuple, because C# tuples are ValueTuple, and an anonymous record for a
projection that never leaves its module.
Three harder questions follow, about label inference in a large codebase, anonymous records in a library’s API, and struct records for millions of values:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A teammate added a Store record with the same Id and City fields as your Shipment record. Now a file that builds shipments with { Id = …; City = … } fails to compile, although nobody touched it. Why, and how do you make record construction robust in a large codebase?
-
Mid to staff
A shared F# library returns anonymous records from its public functions. A minor release added one field to a result, and several services that use the library stopped building. Why did an additive change break them, and how would you design the API instead?
-
Senior to staff
A pricing service keeps 5 million ticks in memory as an array of F# records with three float fields, and its garbage-collection pauses keep growing. Would you make the record a struct? Estimate what changes, and say what you would measure before and after.
Key takeaways
- A tuple groups values by position: patterns take it apart,
fstandsndtake pairs only, and a struct tuple isValueTuple, the type C# tuples use. - A record names its fields and compares by value; its type comes from the expected type, or else from the last declared type with those labels.
- Copy-and-update, nested since F# 8, builds a new record and shares every record it did not change with the old one.
[<Struct>]stores a record inline, 16,000,024 bytes against 40,000,024 for a million two-float points, and[<CLIMutable>]adds the constructor and setters that XmlSerializer needs while F# code still cannot assign.- An anonymous record has exactly its fields, without patterns, so it belongs in projections inside a module.
- Choose the shape by who reads the value: tuples stay local, records cross boundaries.
Terms in this lesson
- anonymous record
- A record written {| Name = "Asha" |} whose type the compiler makes from its field names and types, with no declaration. Two values with the same fields have the same type.
- copy-and-update expression
- A record expression such as { c with City = "Nashik" } that builds a new record from c with the fields it lists replaced. The record c itself is not changed.
- struct tuple
- A tuple written struct (a, b). It compiles to System.ValueTuple, a value type with no object of its own, which is also the type of a C# tuple.
- structural equality
- Comparing two values by their contents: two records are equal when every field is equal, even if they are separate objects. F# records, unions and tuples compare this way by default.
- tuple
- A fixed group of values in order, possibly of different types, such as ("Pune", 1200) of type string * int. Its parts have positions but no names, and patterns take them apart.
- value type
- A .NET type whose values live where they are stored, in a variable, a field or an array element, and are copied on assignment, such as int, a struct or a struct record.
References
- Tuples (Microsoft Learn)
- Records (Microsoft Learn)
- Copy and update record expressions (Microsoft Learn)
- Announcing F# 8 (nested record field copy and update) (Microsoft .NET Blog)
- Anonymous records (Microsoft Learn)
- Structures (struct records and unions) (Microsoft Learn)
- CLIMutableAttribute (FSharp.Core documentation)
- Details of XML serialization (parameterless constructors) (Microsoft Learn)
- System.Text.Json: use immutable types and properties (Microsoft Learn)
- Choosing between class and struct (Microsoft Learn)
- F# component design guidelines (Microsoft Learn)
- GC.GetAllocatedBytesForCurrentThread method (Microsoft Learn)
Report a problem with this lesson
Kept only in this browser. Your Learn progress