Records and immutability
Positional records, value equality you get for free, with-expressions, and why a record holding a List is only shallowly immutable.
After this lesson you can
- Declare a positional record and say which members the compiler generates
- Produce a modified copy with a with-expression instead of mutating
- Spot a record whose immutability stops at a mutable collection inside it
A record is a type whose identity is its data. One line declares it:
public record Book(string Title, int Year);
From that, the compiler generates a constructor, init-only
properties Title and Year, a Deconstruct method, value-based
Equals/GetHashCode and ==/!= over every field, and a readable
ToString(). record alone means a reference type (record class);
record struct gives the same conveniences on a value type, with one
difference: a positional record struct gets ordinary settable
properties, and only readonly record struct makes them init-only.
public record Book(string Title, int Year); public static class Solution{ public static string Describe() { var a = new Book("Dune", 1965); var b = new Book("Dune", 1965); var c = a with { Year = 1966 }; return $"{a} | a==b {a == b} | same object {ReferenceEquals(a, b)} | {c} | a unchanged {a.Year}"; }}a and b are two separate objects (ReferenceEquals is false), yet
== is true, because records compare their data. Compare that with
the plain class in the previous lesson, where two new Point(1, 2)
were never equal.
Changing a record means making a new one
The properties are init-only, so a.Year = 1966 does not compile.
A with-expression copies the record and changes the listed
properties on the copy:
var c = a with { Year = 1966 };
a is untouched. This is called non-destructive mutation, and it is
what makes records comfortable to share between threads or keep as
history: nobody can change a version you are holding.
Immutable is only as deep as the fields
with copies each field as it is. For a value it copies the value; for
a reference it copies the reference. A record that holds a
List<T> therefore shares that list with every copy made from it.
public record Team(string Name, List<string> Members); public static class Solution{ public static string Describe() { var original = new Team("core", new List<string> { "Ana" }); var copy = original with { Name = "core-2" }; copy.Members.Add("Luka"); var twin = new Team("core", new List<string> { "Ana", "Luka" }); return $"original members={string.Join(",", original.Members)} | " + $"original==twin {original == twin} | {original}"; }}Adding to copy.Members changed original.Members, because there was
only ever one list. The same field also explains the equality result:
the generated Equals compares the List field with the list's own
Equals, which is reference equality, so two teams with the same
member names are still unequal. And the generated ToString just
prints the list's type name.
To keep a record genuinely immutable, give it immutable members
(ImmutableList<T>, or an IReadOnlyList<T> that you never cast back)
and build a new collection whenever a with-expression changes it.
Common mistakes
- Assuming
withdeep-copies. Shown above: collections and other reference-type members are shared between the original and the copy. - Expecting a record with a list to compare by contents. Generated
equality uses each member's own
Equals; forList<T>that is reference equality. OverrideEquals(Team? other)andGetHashCodeif you need sequence comparison. - Declaring
setinstead ofiniton a record property. It compiles, but a record you can mutate can change its hash code while it is a dictionary key, the problem the previous lesson showed.
Exercise · Pro
Playlist versions that never change after the fact
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade