Records
The scenario
In C#, an ordinary class compares by reference, by default:
class Point
{
public double X { get; set; }
public double Y { get; set; }
}
var p1 = new Point { X = 1, Y = 2 };
var p2 = new Point { X = 1, Y = 2 };
Console.WriteLine(p1 == p2); // False
p1 and p2 hold the same values, and yet p1 == p2 returns false: they're two distinct memory locations, and an ordinary C# class only compares addresses, not content, unless you've overridden Equals yourself (or used C#'s record keyword, a separate construct this module doesn't assume you know). F#'s record type, on the other hand, behaves this way by default, with nothing special to write.
Declaring a record
type Point = { X: float; Y: float }
The fields (called labels) are listed inside braces, separated by semicolons (or each on its own line). You build an instance with a syntax that looks like a C# object initializer, but without new:
let p1 = { X = 1.0; Y = 2.0 }
let p2 = { X = 1.0; Y = 2.0 }
Structural equality
This is where the behavior diverges from the C# class above:
p1 = p2
This returns true. An F# record has structural equality by default: = compares the value of each field, not the instance's memory address. Two records of the same type, with the same values in every field, are equal — exactly the behavior a C# record also offers by default, but not what an ordinary C# class does.
Changing a field without mutating the original: with
Records are immutable by default, directly following on from module 3: there's no p1.X <- 5.0 for an ordinary field (only a field explicitly declared mutable in the type would allow that, which stays rare). To get a variant of an existing record, you use a copy-and-update expression with the with keyword, which creates a new instance:
let p3 = { p1 with Y = 99.0 }
p3 has X = 1.0 (taken from p1) and Y = 99.0 (the changed value). p1 itself remains unchanged. This is the conceptual equivalent of modern C#'s with expression on its own record types, except here no record keyword is needed on the type at all: it's the normal behavior of any type declared with { ... } in F#.
What structural equality doesn't do
It doesn't replace a reference comparison if that's explicitly what you need (for example, to know whether two variables point to exactly the same object in memory): in that rarer case, F# provides the [<ReferenceEquality>] attribute, applied to the type declaration, to get reference-equality behavior back. That's not the default behavior, and this path doesn't need to go further on this point: just remember that F#'s default choice for a record is structural, the opposite of a C# class's default choice.
Exercise — compare two records and interpret the result
In dotnet fsi, declare this record and these three instances:
type Client = { Name: string; Age: int };;
let a = { Name = "Smith"; Age = 42 };;
let b = { Name = "Smith"; Age = 42 };;
let c = { Name = "Martin"; Age = 42 };;
Before running the following lines, try to predict what they'll display, then check:
a = b;;
a = c;;
Expected outputs:
val it: bool = true
val it: bool = false
a = b is true even though these are two distinct instances created separately: each field has the same value in both, so structural equality considers them equal. a = c is false: the Name field differs, which is enough to make the whole thing different, even though Age matches.
Now create a variant of a with with, without touching a itself:
let d = { a with Age = 43 };;
a = d;;
a.Age;;
Expected outputs:
val it: bool = false
val it: int = 42
a = d is false: with produced a new instance whose Age field differs from a's. And a.Age is still 42 — proof that creating d didn't modify a itself, consistent with the immutability seen in module 3.
What you just did
You declared an F# record, saw that its default equality compares the value of each field rather than the instance's identity — the opposite of an ordinary C# class's default behavior — and used with to produce a variant of a record without modifying the original, consistent with the default immutability seen in module 3.
That was the last new concept in this Beginner tier. Module 7 wraps up the seven modules and has you combine let, |>, match, and records in a single exercise, before announcing what awaits you at the Intermediate tier.
Check your understanding
With type Client = { Name: string; Age: int }, let a = { Name = "Smith"; Age = 42 } and let b = { Name = "Smith"; Age = 42 } created separately, what does a = b return?
Does an ordinary C# class (not a C# record) with the same fields as Client compare two instances with the same values the same way an F# record does?
After let d = { a with Age = 43 }, what is a.Age?
Want to hear about the next modules?
The Academy stays free and open-access, no sign-up required. If you'd just like to be notified by email when a new module ships, here you go — no obligation, unsubscribe anytime with one click.