Skip to main content
ANVISoftware Solutions
Lesson 36 of 62Intermediate18 min

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.

The same data, declared twice, compared twice
C#
// 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.
Changing one value without mutating anything
C#
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:

 recordclass
Equality by defaultAll declared values matchSame object in memory
ToString by defaultType name plus every valueType name only
Copy with one changewith expression, built inWrite a copy method and maintain it
MutabilityPositional properties are init-only unless you declare otherwiseWhatever you declare
Validation in the constructorPossible, but less direct — the positional constructor has no body of its ownThe natural place for it
Fits bestData that travels: DTOs, API payloads, query results, messages, value objectsThings 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.

C#
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 objects

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

Two OrderLine records are created with the same Sku, Quantity and UnitPrice. What does first == second return?

Saved in this browser only.