F# from the ground up

Records: your own named data

A record gives each part of a value a name and a type. You create one with its field names, copy one to change a field, and both operations leave the original exactly as it was.

Part 8 of 12

Intermediate18 min readBack to Courses

A type with named fields

A record is a type whose fields have names: type Book = { Title: string; Pages: int }. Declaring it creates no book; it says what any book has, in the same way int says what a number is.

A value is written with braces and field names: { Title = "Dune"; Pages = 412 }. The field names are what identify the type, which is why the compiler usually knows what you meant without an annotation. Every field has to be given, though the order you write them in does not matter.

Type declarations go above their first use. A script may hold several, and they are only visible from the line where they are declared downwards, which is the same rule as every other binding in the file.

Copying with one field changed

{ dune with Pages = 336 } builds a new record: the fields copied, the named ones replaced. The original is untouched — the immutability of the second chapter, now applied to data. A function that "updates" a book answers a new book, and its type says so.

Records compare by value, so two books with the same title and page count are equal. That is what lets List.filter and List.sortBy work on records without any code from you: the comparison is the fields, compared in the order they were declared.

A with copy is shallow. If a field is itself a record, the new record shares that inner value with the old one; changing a field inside it means another with on the inner record and then a with on the outer one. That is fine because nothing is mutable, but it explains why a deeply nested update is several expressions rather than one path assignment.

A record type, a value, and a copy

type Book = { Title: string; Pages: int }

let dune = { Title = "Dune"; Pages = 412 }
let shorter = { dune with Pages = 336 }

let longer book = { book with Pages = book.Pages + 100 }

Two books, a copy and a function

Run it and confirm that the last two lines differ. Then add a `Pages` change to `dune` through a copy and print the original afterwards to show it did not move.

Output
Click "Run" to execute your code

%A will print a record while you are debugging, but it spreads the fields over several lines, so a report prints the fields it wants with a format string. Name the fields so that reading a field name is enough to know what it holds.

The field names identify the type

When you write { Title = "Dune"; Pages = 412 }, the compiler does not need the type name. It looks at the labels and finds the record type that has them, which is why construction reads like a description. If two record types happen to share every label, the most recently declared one wins, and that is the moment to annotate: let dune: Book = { ... } says which one you mean.

The declaration and the value use different punctuation. In type Book = { Title: string; Pages: int } the colon gives each field its type; in a value, the equals sign gives each field its contents. Mixing them is a common first error, and the message points at the field rather than at the whole line.

A field is reached with a dot, and that dot is a function: book.Pages reads the int out of a Book. It works anywhere the compiler knows the record type, including inside the lambdas passed to List.filter and List.sortBy.

A list of records is still a list

Once the record exists, every list helper in the course applies without new machinery. Filter on a field, sort by a field, total a field, group by a field: each is one lambda. The record has moved the meaning into names, but the list does not know or care.

Records compare field by field in declaration order, and that comparison is what drives sorting and equality. Two records built separately with the same contents are equal, which is why a test can compare a result with an expected record directly. It is also why sorting a shelf by title needs no comparison function of your own.

List.sumBy (fun book -> book.Pages) shelf is the record version of a total: the lambda reads one field and the list function adds the results. List.countBy, List.groupBy and List.maxBy take the same kind of key function, so a report over records is a handful of field reads.

A new record must give every field, or the compiler rejects it; with is the form that fills the rest. The declaration uses :, so Title = string there is not a type. And with keeps the same record type, so it cannot turn one kind of record into another.

Try these next

  • Add an Author field to the record, then fix every construction the compiler flags.
  • Filter the shelf to books over 300 pages and sort the result by title.
  • Declare a second record with the same field names and see where an annotation becomes necessary.

A shelf sorted, filtered and copied

Run it and check which two books are long enough for the first list. Then add a fourth book, sort the whole shelf by author, and copy the shortest one with a new title.

Output
Click "Run" to execute your code

Check what you learned

One question at a time. A wrong answer explains itself — take another go.

Part 1 of 5

What does { dune with Pages = 336 } do to dune?