Records
By the end of this lesson
Declare immutable data types concisely and copy them with with expressions.
Records are usually introduced as a shorter way to write a class. That undersells them and sets the wrong expectation, because brevity is the side effect.
The real change is what equality means. Two objects of a class are equal when they are the same object. Two records are equal when they hold the same values. Some things in a program have an identity that survives their values changing — an employee is the same employee after a pay rise. Other things are nothing but their values: an amount of money, a date range, a snapshot sent to an API. A record is the type for the second kind.
// A record. One line, and equality is by value.
public record EmployeeSnapshot(string EmployeeId, string FullName, decimal AnnualSalary);
// The same shape as a class.
public class EmployeeSnapshotClass
{
public string EmployeeId { get; init; } = "";
public string FullName { get; init; } = "";
public decimal AnnualSalary { get; init; }
}
EmployeeSnapshot recordA = new EmployeeSnapshot("E-1042", "Asha Rao", 1_200_000m);
EmployeeSnapshot recordB = new EmployeeSnapshot("E-1042", "Asha Rao", 1_200_000m);
Console.WriteLine(recordA == recordB); // True
Console.WriteLine(recordA.Equals(recordB)); // True
EmployeeSnapshotClass classA = new EmployeeSnapshotClass
{
EmployeeId = "E-1042", FullName = "Asha Rao", AnnualSalary = 1_200_000m
};
EmployeeSnapshotClass classB = new EmployeeSnapshotClass
{
EmployeeId = "E-1042", FullName = "Asha Rao", AnnualSalary = 1_200_000m
};
Console.WriteLine(classA == classB); // False
Console.WriteLine(classA.Equals(classB)); // False
Console.WriteLine(recordA);
// EmployeeSnapshot { EmployeeId = E-1042, FullName = Asha Rao, AnnualSalary = 1200000 }
Console.WriteLine(classA);
// EmployeeSnapshotClass- The parameters in EmployeeSnapshot(...) are a positional record declaration. Each one becomes a public property with an init accessor, which means it can be set at creation and not afterwards.
- The two records compare equal because the compiler generated an Equals that checks the runtime type and then every declared value. == is wired to it as well.
- The two class instances compare unequal despite holding identical data. The default Equals for a class asks "is this the same object?", and these are two objects.
- ToString differs too. The record prints its values, which makes a log line or a debugger watch window immediately useful. The class prints its type name and nothing else.
- Nothing here is exclusive to records — you could write all of this by hand on the class. Getting Equals, GetHashCode, == and != consistent with each other by hand is about sixty lines of code that has to be revisited every time a property is added.
What the compiler writes for a record, so you do not:
- Value equality
- Equals, GetHashCode, == and !=, all consistent with each other and all derived from the declared properties. Add a property and they update themselves.
- Init-only properties
- Each positional parameter becomes a public property that can be assigned at creation and then not changed.
- A readable ToString
- The type name followed by every property and value. Worth having in logs alone.
- Deconstruct
- Lets you pull the values straight out: (string id, string name, decimal salary) = snapshot;
- A copy constructor
- The mechanism behind with expressions. It copies every field, and then the with block overwrites the ones you named.
public record OrderLine(string Sku, int Quantity, decimal UnitPrice)
{
// Records can hold computed members. This one is derived, so it takes no
// part in equality — there is no stored field behind it to compare.
public decimal LineTotal => Quantity * UnitPrice;
}
OrderLine original = new OrderLine("DL-1001", 2, 1_450m);
// "Everything the same, except the quantity."
OrderLine corrected = original with { Quantity = 3 };
Console.WriteLine(original.LineTotal); // 2900
Console.WriteLine(corrected.LineTotal); // 4350
Console.WriteLine(ReferenceEquals(original, corrected)); // False — a new object
Console.WriteLine(original == corrected); // False — different values
// Unchanged copies are equal, which is often exactly what you want in a test.
OrderLine untouched = original with { };
Console.WriteLine(original == untouched); // True- with produces a new object. The original is untouched, so any other code holding a reference to it sees no change. That is the point of the pattern: nothing can be modified behind your back.
- Inside the braces you name only what differs. Everything else is copied. Adding a fourth property to OrderLine does not touch this call site.
- LineTotal recalculates on the new object, because it is computed on every read rather than stored.
- with makes a shallow copy. A property holding a reference to another object gives the copy a reference to the same object, not a copy of it.
- original == untouched being True is the value-equality rule again. Two separate objects, same values, equal.
Choosing between the two:
| record | class | |
|---|---|---|
| Equality by default | All declared values match | Same object in memory |
| ToString by default | Type name plus every value | Type name only |
| Copy with one change | with expression, built in | Write a copy method and maintain it |
| Mutability | Positional properties are init-only unless you declare otherwise | Whatever you declare |
| Validation in the constructor | Possible, but less direct — the positional constructor has no body of its own | The natural place for it |
| Fits best | Data that travels: DTOs, API payloads, query results, messages, value objects | Things with identity, a lifecycle, or rules to protect |
Summary
- A record's headline feature is value equality: equal contents mean equal objects
- Positional parameters become init-only properties, so a record is immutable unless you declare otherwise
- with copies an object and overwrites only the properties you name, leaving the original untouched
- The copy is shallow, and a mutable collection inside a record is still compared by reference
- Records suit data that travels; types with identity, a lifecycle or real behaviour are better as classes
Practice
Attempt each one before opening the solution. Getting it wrong first is how the idea sticks.
Try it yourself
Try it yourself
Write a record ShippingAddress with a line, a city, a state and a postcode. Create two instances with identical values and confirm they are equal.
Then use a with expression to produce a copy with a different city, and confirm the original is unchanged.
Finally, add a List<string> DeliveryNotes property and check whether two records with identical notes are still equal.
Show solution
The first two parts behave as the lesson describes. The third is the interesting one: the two records stop being equal, because the generated Equals compares the two List<string> references and they are different objects.
This is not a flaw in records, it is value equality doing what it says. It compares each property using that property's own notion of equality, and a mutable list's notion is reference identity. The lesson is to be deliberate about what you put inside a type whose equality matters.
Two reasonable fixes. Hold an immutable collection type that compares by value, or override Equals and GetHashCode to compare the contents. There is a third answer worth considering: if the notes are not part of what makes an address the same address, they may belong on a different type altogether.
public record ShippingAddress(string Line, string City, string State, string Postcode);
ShippingAddress first = new ShippingAddress("14 Nehru Road", "Pune", "MH", "411001");
ShippingAddress second = new ShippingAddress("14 Nehru Road", "Pune", "MH", "411001");
Console.WriteLine(first == second); // True
ShippingAddress moved = first with { City = "Nashik" };
Console.WriteLine(first.City); // Pune — untouched
Console.WriteLine(moved.City); // Nashik
Console.WriteLine(first == moved); // False
// Now with a mutable collection inside:
public record AddressWithNotes(string Line, string City, List<string> DeliveryNotes);
AddressWithNotes a = new AddressWithNotes("14 Nehru Road", "Pune", new List<string> { "Gate 2" });
AddressWithNotes b = new AddressWithNotes("14 Nehru Road", "Pune", new List<string> { "Gate 2" });
Console.WriteLine(a == b); // False — two different List objectsThink about it
Think about it
Your Employee type is stored in a database and identified by an EmployeeId. A colleague proposes converting it to a record so that comparing two employees in tests becomes easier.
What breaks, and what would you suggest instead?
Show solution
Equality becomes wrong in a way that is hard to spot. After a pay rise, the employee loaded this morning and the employee loaded this afternoon report as different employees, because one value differs. Any code using a set, a dictionary key or a Contains check now behaves differently depending on data that has nothing to do with identity.
The init-only properties get in the way too. An entity changes over its life, and the mapper needs to write to it when loading and track changes when saving. Fighting that with with expressions means replacing objects the mapper is tracking.
The colleague's actual problem is real, though: comparing two employees in a test is awkward. Two better answers. Project the entity into a record for the assertion, which compares exactly the fields the test cares about. Or keep the class and give it explicit equality by EmployeeId, which is what identity means for this type. Both keep the entity a class and give the test its value comparison.
Knowledge check
Nothing is recorded and there is no score. The explanation appears either way.
Saved in this browser only.