Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Beginner
  • 15 minutes
  • Examples run with .NET 10.0.401 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 15 minutes
Free: the whole lesson. With a pass: the model answers to its 3 interview questions.

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:

Tuples and struct tuples at the prompt Python · tuples_session.py
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

  1. Question 1 of 2 sale is ("Pune", 1200, 3): a city, a total and a count of items. A caller writes let (city, items, total) = sale. What happens?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It compiles, and items holds 1200 while total holds 3

    A tuple pattern binds by position, and the names you write are yours. Swapping two parts of the same type compiles silently, which is why values that travel get named fields.

  2. Question 2 of 2 point is struct (3, 4). Which line takes it apart?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: let struct (x, y) = point

    Reference and struct tuples never convert by themselves. Match the form: a struct pattern for a struct tuple, a plain pattern for a reference tuple.

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:

records.fsx: building, comparing and matching records FSX · records.fsx
// 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])
records.fsx, then two record types with the same labels Python · records_session.py
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:

Interactive · stage by stage Which type { Id = 7; City = "Pune" } gets Step down the checks the compiler makes for a record expression whose labels belong to two types, Shipment and Store.
Which type { Id = 7; City = "Pune" } gets, step 5 of 5. Showing A record expression, Is a type expected? (no: let s = { … }), Look up the labels (Shipment and Store fit), The latest type wins, ship s (FS0001: expected Shipment). In focus: ship s.A record expression{ Id = 7; City = "Pune" }Is a type expected?no: let s = { … }Look up the labelsShipment and Store fitThe latest type winsStore, declared lastship sFS0001: expected Shipmentfix: { Shipment.Id = 7; … }

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. 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.
  2. 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".
  3. 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.
  4. 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.
  5. 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

  1. Question 1 of 2 home and sameHome were built separately with the same City and Pin. What did records.fsx print for home = sameHome and for obj.ReferenceEquals(home, sameHome)?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: true, then false

    = on records compares their fields. ReferenceEquals asks whether two names point at one object, which two record expressions never do.

  2. Question 2 of 2 Shipment and then Store are both declared as { Id: int; City: string }. What type does let s = { Id = 7; City = "Pune" } get?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Store, the type declared last

    With no expected type, a record expression takes the last declared type that has its labels. Name the type, as in { Shipment.Id = 7; City = "Pune" }, when two types share every label.

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:

copy.fsx: a nested update, written the F# 8 way and the old way FSX · copy.fsx
// 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)
copy.fsx, then a field named like its own type Python · copy_session.py
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:

Interactive · step by step What { asha with Home.City = … } builds and what it shares Step through the update. Watch which records are new and which ones the old and the new customer share.
What { asha with Home.City = … } builds and what it shares, step 5 of 5. Showing asha, Home: Pune, Office: Pune (one object, two owners), moved, Home: Nashik. In focus: Office: Pune.ashaName "Asha"Home: PunePin 411001Office: Puneone object, two ownersmovedName "Asha"Home: NashikPin 422001

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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

  1. Question 1 of 2 After let moved = { asha with Home.City = "Nashik"; Home.Pin = "422001" }, what is asha.Home.City?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: "Pune"

    A copy-and-update expression returns a new record and leaves the original exactly as it was.

  2. Question 2 of 2 The update changed only Home. What did copy.fsx print for obj.ReferenceEquals(asha.Office, moved.Office)?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: true: both customers hold the same Office record

    A copy-and-update shares every record it does not change. That is safe because a record cannot be changed by anyone who shares it.

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:

layout.fsx: struct records measured, CLIMutable records serialized FSX · layout.fsx
// 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
layout.fsx, then an assignment to a CLIMutable record Python · layout_session.py
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:

Interactive · stage by stage How XmlSerializer builds a [<CLIMutable>] record Step down what the serializer does with the XML of one row. It builds an empty object first and fills it in afterwards.
How XmlSerializer builds a [<CLIMutable>] record, step 5 of 5. Showing Read <RowM>, Call new RowM() (exists only for RowM), Set Name, Set Total, A RowM record. In focus: A RowM record.Read <RowM>an element with two childrenCall new RowM()exists only for RowMSet Namefrom <Name>Asha</Name>Set Totalfrom <Total>1200</Total>A RowM recordAsha, 1200

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. The second field. Total is set from its element the same way, converted from the text 1200 to a decimal.
  5. 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

  1. Question 1 of 2 A million reference records of two floats took 40,000,024 bytes, of which the array of references took 8,000,024. How many bytes did each point object take?

    Type a number.

    A hint

    Take the array away, then share what is left among the million points.

    Show the answer: Question 1 of 2

    Answer: 32 bytes

    40,000,024 − 8,000,024 = 32,000,000 bytes for 1,000,000 objects, 32 bytes each: twice the 16 bytes of the two floats. As struct records the same points took 16,000,024 bytes in all.

  2. Question 2 of 2 XmlSerializer refused Row but read RowM, which has [<CLIMutable>]. What did the attribute add?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A parameterless constructor and property setters, which XmlSerializer uses to build the value

    [<CLIMutable>] compiles the record with a parameterless constructor and setters for code such as XmlSerializer that builds an empty object first. To F# code the record is as immutable as before.

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.fsx: a projection of sales by city FSX · anonymous.fsx
// 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
anonymous.fsx, then what anonymous records refuse Python · anonymous_session.py
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

  1. Question 1 of 2 total takes {| Name: string; Total: int |}. Why was total {| a with City = "Pune" |} refused?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The copy added City, which makes a different type, and an anonymous record matches exact fields only

    An anonymous record's type is its exact set of fields. One more or one fewer field is another type, so FS0001 reports the difference.

  2. Question 2 of 2 How do you read the Name of an anonymous record a?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: a.Name

    Read an anonymous record's fields with a dot. Where you would want a pattern, the value has probably earned a named record.

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:

Interactive · answer and compare Tuple, record or anonymous record? Answer three questions about the value you are about to group. The table marks the shapes that would make your case harder.
Does the value leave the function or module that makes it?
How many values does it group?
Will an array hold millions of them?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Five ways to group values against the four things that differ between them, with this lesson's measurements
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

  1. One question A function in a shared library returns a customer's name, city and order count to code in other projects. Which shape should it return?

    Choose one answer.

    Show the answer: One question

    Answer: A record type declared in the library

    A value that crosses into other code gets a named record. Tuples stay local, and anonymous records stay inside one module.

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:

practice.fsx: moveCustomer and summary FSX · practice.fsx
// 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" } })
practice.fsx on the pinned SDK Python · practice_session.py
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.

  1. Question 1 of 12 What type does F# Interactive give ("Pune", 1200, 3)?

    Choose one answer.

    Show the answer: Question 1 of 12

    Answer: string * int * int

    The type of a tuple lists the types of its parts in order, separated by . The session answered val sale: string int * int.

  2. Question 2 of 12 A C# method returns (int Count, decimal Total). How do you take its result apart in F#?

    Choose one answer.

    Show the answer: Question 2 of 12

    Answer: let struct (count, total) = result

    C# tuples are structs, says the tuples reference, so they meet F# as struct tuples, read with let struct (…).

  3. Question 3 of 12 Shipment has the fields Id and City. What does { Shipment.Id = 8 } give?

    Choose one answer.

    Show the answer: Question 3 of 12

    Answer: Error FS0764: no assignment given for field City

    Every field of a record must be given when it is built. For defaults, build one default value and copy it with the fields that differ.

  4. Question 4 of 12 List.sort is given Pune 411014, Agra 282001 and Pune 411001, as Address records with City declared before Pin. Which comes first and which last?

    Choose one answer.

    Show the answer: Question 4 of 12

    Answer: Agra 282001 first, Pune 411014 last

    compare on records takes the fields in the order they are declared. records.fsx printed Agra 282001, Pune 411001 and Pune 411014.

  5. Question 5 of 12 Which expression is the pre-F# 8 form of { asha with Home.City = "Nashik" }?

    Choose one answer.

    Show the answer: Question 5 of 12

    Answer: { asha with Home = { asha.Home with City = "Nashik" } }

    The nested form copies the outer record and, inside it, the inner one. copy.fsx built both and found moved = movedOld true.

  6. Question 6 of 12 Person has the field Address: Address. { ravi with Address.City = "Kanpur" } fails with FS0001. Which expression compiles?

    Choose one answer.

    Show the answer: Question 6 of 12

    Answer: { ravi with Person.Address.City = "Kanpur" }

    Address.City is read the older way, as the field City of the type Address, which the F# 8 announcement says still takes priority. Qualify the update with the record's own type.

  7. Question 7 of 12 Which record gains most from [<Struct>]?

    Choose one answer.

    Show the answer: Question 7 of 12

    Answer: A two-float point kept in arrays of millions of values

    [<Struct>] pays for small, immutable values held in large arrays. Wide records passed around stay reference types.

  8. Question 8 of 12 What changes for F# code when a record gets [<CLIMutable>]?

    Choose one answer.

    Show the answer: Question 8 of 12

    Answer: Nothing; it is built and copied as before, and assignment is still error FS0005

    [<CLIMutable>] changes the compiled class, not the F# view of it.

  9. Question 9 of 12 Is {| Name = "Asha"; Total = 1200 |} = {| Total = 1200; Name = "Asha" |} true?

    Choose one answer.

    Show the answer: Question 9 of 12

    Answer: Yes; both have the fields Name and Total, so they are one type, compared field by field

    An anonymous record's type is its set of field names and types. Two values with the same set are equal when their fields are equal.

  10. Question 10 of 12 rows is a list of anonymous records, and List.sumBy _.Total rows fails with FS0072. Which fix works?

    Choose one answer.

    Show the answer: Question 10 of 12

    Answer: rows |> List.sumBy _.Total

    A field of an anonymous record is found from the type, which must be known first. Piping the list in gives the compiler that type before the lambda.

  11. Question 11 of 12 A private helper used only inside one function returns a quotient and a remainder. Which shape fits best?

    Choose one answer.

    Show the answer: Question 11 of 12

    Answer: A tuple, (quotient, remainder)

    Tuples are for a small number of unrelated values close to where they are made. Values that travel get named fields.

  12. Question 12 of 12 A simulation keeps 10 million (x, y) positions in an array and updates them every frame. Which shape fits?

    Choose one answer.

    Show the answer: Question 12 of 12

    Answer: A [<Struct>] record with X and Y

    For millions of small values held in arrays, a struct record keeps names and stores the values inline: 16,000,024 bytes for a million two-float points against 40,000,024.

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.

  1. 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?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

  3. 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.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • A tuple groups values by position: patterns take it apart, fst and snd take pairs only, and a struct tuple is ValueTuple, 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

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.