Yesterday you learned about nullable types and null safety — how to stop NullReferenceException from haunting your codebase like a poltergeist with commitment issues. You wrestled ?, ??, ??=, and the null-forgiving operator into submission. Today we're building immutable data types that compare by value and matching them with switch expressions like a pro. If classes are houses you build brick by brick, records are prefab modular homes — they arrive ready to go, and two identical prefabs are considered the same house. Pair that with pattern matching, and you've got a sorting hat that actually reads the label.
1. What Are Records? — Your Data's Passport Photo
A record is a reference type designed specifically to hold data. Think of it like a passport: two passports with the same name, birthdate, and nationality are considered "the same person" even if they're physically different booklets sitting on different desks. That's value-based equality — records compare what's inside, not which object reference you're holding.
Records are also immutable by default. Once you create one, you don't change it — you make a new copy with modifications. Like how you don't scribble over your old passport; you apply for a new one and pretend the old photo never happened.
public record Person(string FirstName, string LastName, int Age);
That single line gives you:
- A class with three read-only properties
- A constructor that takes all three
Equals()andGetHashCode()based on property values- A
ToString()that actually prints something useful (not just the type name like a class would) - Deconstruction support
One line. The compiler writes hundreds of lines of boilerplate behind the scenes. It's like hiring a very fast intern who never complains.
2. Record vs Class — The Identity Crisis
Here's the thing that trips people up. Classes compare by reference — are these two variables pointing to the same object in memory? Records compare by value — do these two objects contain the same data?
// With a regular class
var class1 = new PersonClass("Ada", "Lovelace", 36);
var class2 = new PersonClass("Ada", "Lovelace", 36);
Console.WriteLine(class1 == class2); // False — different objects!
// With a record
var record1 = new Person("Ada", "Lovelace", 36);
var record2 = new Person("Ada", "Lovelace", 36);
Console.WriteLine(record1 == record2); // True — same values!
Think of it this way: you and your neighbor both order the exact same IKEA bookshelf. Classes would say "these are different bookshelves because they're in different houses." Records would say "same bookshelf, same screws, same missing Allen wrench — they're equal."
| Feature | class |
record |
|---|---|---|
| Equality | Reference (identity) | Value (content) |
| Mutability | Mutable by default | Immutable by default |
ToString() |
Type name only | All property values |
| Intended use | Behavior + state | Data transfer |
| Boilerplate | You write it all | Compiler writes it |
Use classes when identity matters — a BankAccount object should be unique even if two accounts happen to have the same balance. Use records when the data IS the identity — a Coordinate with X=5, Y=10 is the same point regardless of which variable holds it.
3. Your First Record — Two Flavors of Declaration
Records come in two syntaxes. Positional syntax is the shorthand we already saw:
public record Person(string FirstName, string LastName, int Age);
This creates init-only properties. You set them at creation, then they're locked. Done. No takebacks.
Property syntax gives you more control — useful when you need validation or computed properties:
public record Product
{
public required string Name { get; init; }
public decimal Price { get; init; }
public string Display => $"{Name}: ${Price:F2}";
}
You can also mix them — positional parameters plus extra properties in the body:
public record Order(int Id, string Customer)
{
public DateTime CreatedAt { get; init; } = DateTime.UtcNow;
public bool IsProcessed { get; set; } = false;
}
Wait — a mutable property on a record? You can do this, but it's like putting wheels on a house. Technically possible. Architecturally questionable. The other developers on your team will give you the look.
4. With Expressions — Copy, Tweak, Done
Since records are immutable, you can't just change a property. But you can create a modified copy using the with expression. It's like photocopying a form and whiting out one field:
var original = new Person("Alan", "Turing", 41);
var updated = original with { Age = 42 };
Console.WriteLine(original); // Person { FirstName = Alan, LastName = Turing, Age = 41 }
Console.WriteLine(updated); // Person { FirstName = Alan, LastName = Turing, Age = 42 }
Console.WriteLine(original == updated); // False — different Age
The with expression copies every property and only overrides the ones you specify. The original stays untouched. No mutations. No side effects. No "who changed my object at 3 AM" debugging sessions.
You can change multiple properties at once:
var married = original with { LastName = "Turing-Smith", Age = 42 };
This is non-destructive transformation — a fancy term for "make a copy, change some stuff." Functional programmers have been doing this for decades and feeling very smug about it. Now you can too.
5. Positional Records and Deconstruction
Positional records automatically support deconstruction — pulling the values back out into separate variables:
var person = new Person("Grace", "Hopper", 85);
var (first, last, age) = person;
Console.WriteLine($"{first} {last} was {age}");
// Grace Hopper was 85
The compiler generates a Deconstruct method for you. You don't have to write it. You don't even have to think about it. It just works — like a vending machine, but one that never eats your dollar.
You can also use deconstruction in foreach loops:
List<Person> scientists =
[
new("Ada", "Lovelace", 36),
new("Alan", "Turing", 41),
new("Grace", "Hopper", 85)
];
foreach (var (first, last, _) in scientists)
{
Console.WriteLine($"{first} {last}");
}
The _ is a discard — it means "I don't care about this value." Like when someone asks how your weekend was on a Monday and you say "fine" without elaborating.
6. Record Structs — Records, but on the Stack
Regular record types are reference types — they live on the heap. If you want a value type that still gets all the record goodness, use record struct:
public record struct Coordinate(double X, double Y);
This gives you value-based equality, a useful ToString(), and with expressions — but the data lives on the stack. No heap allocation. No garbage collection overhead. Just vibes and raw performance.
There's also readonly record struct for full immutability:
public readonly record struct Temperature(double Celsius)
{
public double Fahrenheit => Celsius * 9.0 / 5.0 + 32;
}
Here's the quick comparison:
| Type | Reference/Value | Mutable by default? |
|---|---|---|
record (or record class) |
Reference (heap) | No (init-only) |
record struct |
Value (stack) | Yes |
readonly record struct |
Value (stack) | No |
Use record struct when your data is small — coordinates, colors, money amounts — and you're creating lots of them. The stack allocation avoids stressing the garbage collector, which is already overworked and considering a career change.
7. Advanced Pattern Matching — The Sorting Hat Gets Smarter
You've seen basic switch statements before. Pattern matching takes that concept and straps a jetpack to it. You can match on types, properties, values, and combinations — all in a single expression.
Property patterns let you match directly on an object's properties:
string Describe(Person person) => person switch
{
{ Age: < 13 } => "Child",
{ Age: < 20 } => "Teenager",
{ Age: < 65 } => "Adult",
{ Age: >= 65 } => "Senior",
_ => "Unknown"
};
No if/else if/else if/else if/else tower of doom. Just clean, declarative matching.
Relational patterns use <, >, <=, >= directly inside the pattern:
string GetGrade(int score) => score switch
{
>= 90 => "A",
>= 80 => "B",
>= 70 => "C",
>= 60 => "D",
_ => "F — but hey, at least you showed up"
};
Logical patterns combine conditions with and, or, and not:
string ClassifyTemperature(int temp) => temp switch
{
< 0 => "Freezing",
>= 0 and < 15 => "Cold",
>= 15 and < 25 => "Comfortable",
>= 25 and < 35 => "Warm",
>= 35 => "Why do you live here?"
};
And not is great for null checks and exclusions:
if (person is not null and { Age: > 0 })
{
Console.WriteLine($"{person.FirstName} exists and has a valid age");
}
if (value is not null) reads like English. That's more than you can say for most C# syntax from the pre-pattern-matching era.
8. Records + Pattern Matching — A Match Made in the Compiler
Here's where it all comes together. Records give you clean data types. Pattern matching gives you clean logic over those types. Together, they replace massive if/else chains with something you can actually read without squinting.
Let's model a notification system:
public record Notification(string Message, DateTime Sent);
public record EmailNotification(string Message, DateTime Sent, string Address)
: Notification(Message, Sent);
public record SmsNotification(string Message, DateTime Sent, string PhoneNumber)
: Notification(Message, Sent);
public record PushNotification(string Message, DateTime Sent, string DeviceId, bool IsSilent)
: Notification(Message, Sent);
Records support inheritance — you can build hierarchies just like classes. Now let's route them:
string Route(Notification notification) => notification switch
{
EmailNotification { Address: var addr } when addr.EndsWith("@spam.com")
=> "Junk folder — nice try",
EmailNotification e
=> $"Send email to {e.Address}",
SmsNotification { PhoneNumber.Length: > 10 }
=> "International SMS gateway",
SmsNotification s
=> $"Send SMS to {s.PhoneNumber}",
PushNotification { IsSilent: true }
=> "Silent push — no sound, no fury",
PushNotification p
=> $"Push to device {p.DeviceId}",
_ => "Unknown notification — carrier pigeon?"
};
No casting. No is checks followed by manual property access. The pattern match handles type checking, property extraction, and conditional logic in one expression. It's like a Swiss Army knife where every tool actually works and none of them are the weird corkscrew you never use.
You can also use positional patterns with record deconstruction:
string DescribePoint(Coordinate point) => point switch
{
(0, 0) => "Origin",
(var x, 0) => $"On the X-axis at {x}",
(0, var y) => $"On the Y-axis at {y}",
(var x, var y) when x == y => $"On the diagonal at {x}",
(var x, var y) => $"Point at ({x}, {y})"
};
Each arm destructures the record and binds variables in one shot. The compiler checks that you've covered all cases — if you miss one, you get a warning. It's like a safety net that actually catches you.
9. Your Homework: Shape Calculator
Model a shape hierarchy using records and use pattern matching to calculate the area. Here's your starter:
public record Shape;
public record Circle(double Radius) : Shape;
public record Rectangle(double Width, double Height) : Shape;
public record Triangle(double Base, double Height) : Shape;
public record Square(double Side) : Shape;
// Implement this method using a switch expression
static double CalculateArea(Shape shape) => shape switch
{
Circle { Radius: var r } => Math.PI * r * r,
Rectangle(var w, var h) => w * h,
Triangle(var b, var h) => 0.5 * b * h,
Square { Side: var s } => s * s,
_ => throw new ArgumentException($"Unknown shape: {shape}")
};
// Bonus: describe the shape using advanced patterns
static string Describe(Shape shape) => shape switch
{
Circle { Radius: < 1 } => "A tiny circle",
Circle { Radius: >= 1 and < 10 } => "A medium circle",
Circle { Radius: >= 10 } => "A massive circle",
Rectangle r when r.Width == r.Height => "That's actually a square in disguise...",
Rectangle => "A rectangle",
Triangle(_, var h) when h > 100 => "A very tall triangle",
Triangle => "A triangle",
Square => "A square",
_ => "Some mystery shape from another dimension"
};
// Test it
List<Shape> shapes =
[
new Circle(5),
new Rectangle(4, 7),
new Triangle(3, 6),
new Square(10),
new Circle(0.5),
new Rectangle(5, 5)
];
foreach (var shape in shapes)
{
Console.WriteLine($"{Describe(shape)} — Area: {CalculateArea(shape):F2}");
}
Stretch goal: Add a Pentagon record and handle it in both methods. Notice how the compiler warns you about the unhandled case if you forget — exhaustiveness checking has your back like a very strict but loving hall monitor.
Summary of Day 20
- Records are reference types designed for immutable data with value-based equality — two records with the same properties are equal, full stop.
- Use positional syntax (
record Person(string Name, int Age)) for concise declarations, or property syntax for more control. - The
withexpression creates modified copies without mutating the original — functional programming made friendly. - Positional records automatically support deconstruction into separate variables.
record structgives you value-type records on the stack; addreadonlyfor full immutability.- Property patterns match on an object's properties; relational patterns use comparison operators; logical patterns combine conditions with
and,or, andnot. - Records and pattern matching together replace verbose type-checking
if/elsechains with concise, readable switch expressions.
Tomorrow: we'll explore Tuples and Deconstruction — returning multiple values from methods without packing everything into a class. Think of it as records' casual cousin who shows up to the party in flip-flops and somehow still gets the job done. 🚀
See you on Day 21!