Skip to main content
CodeOath
← All posts

C#64 min total · 17 parts

C# Fundamentals: Value Types, Reference Types, Boxing, and the Type System

Part 7 of 17 · ~3 min

Records: Value-Like Semantics Without Giving Up Reference Types

The warehouse system needs one more kind of data: a price quote handed to a customer at checkout. It has to compare equal to another quote with the same numbers (two carts quoting the same SKU at the same price shouldn't be "different" objects for no reason), and — this is the part a plain class or struct doesn't give you for free — it has to support "here's an adjusted version" without ever mutating the quote a customer already saw on screen.

C# 9 added record to the language, and underneath it's still a reference type — what changes is that the compiler writes Equals, GetHashCode, and ToString for you, comparing by content rather than by identity. That's the GetHashCode-pairing problem from the previous chapter, solved permanently, without you ever having to type either method:

record PriceQuote(string Sku, decimal UnitPrice);

var quote1 = new PriceQuote("WDG-100", 4.50m);
var quote2 = new PriceQuote("WDG-100", 4.50m);
Console.WriteLine(quote1 == quote2);      // true — records compare by value automatically
Console.WriteLine(quote1.Equals(quote2)); // true
Console.WriteLine(quote1);                // "PriceQuote { Sku = WDG-100, UnitPrice = 4.50 }"

Records also give you non-destructive mutation for free, through with: name the properties that should be different and it hands back a whole new record built from them, while the record you called it on sits there completely untouched:

var repriced = quote1 with { UnitPrice = 5.25m };
Console.WriteLine(quote1);     // PriceQuote { Sku = WDG-100, UnitPrice = 4.50 } — unchanged
Console.WriteLine(repriced);   // PriceQuote { Sku = WDG-100, UnitPrice = 5.25 }

That's precisely what "apply a promotion" needs to do: hand back an adjusted quote without any risk of a shared reference silently changing the number a customer is already looking at on their screen.

There's a fourth combination the language offers too: put struct in front of record and you keep the copy-on-assignment behavior of a genuine value type while still getting the compiler-written equality a plain record gets — no reflection, no hand-written Equals, none of the tradeoffs either extreme normally forces on you:

record struct SkuLocation(string Aisle, string Bin);

var a = new SkuLocation("A12", "B3");
var b = a;              // copies the data — a genuine struct copy
b = b with { Bin = "B4" };
Console.WriteLine(a);   // SkuLocation { Aisle = A12, Bin = B3 } — untouched, a and b were never linked
Console.WriteLine(a == b); // false — different Bin values
Question you're actually askingclassrecordstructrecord struct
Assign it to a second variable — copy or share?sharesharecopycopy
Two separately-built instances, same field values — equal?no, unless you write Equals yourselfyes, out of the boxyes, but through slow reflection unless you write Equals yourselfyes, out of the box, and fast
Need an edited version without touching the original?write your own copy constructorwith handles itwrite your own copy constructorwith handles it

Put that way, record struct isn't really a fifth option so much as it's the answer to a problem raised two rows up: it hands you correct, fast, hand-generated-quality equality on something that still has genuine copy semantics, with no way to forget pairing Equals and GetHashCode because you never wrote either one.