Records, Pattern Matching & Modern C# - Less Boilerplate, More Safety
Back in Phase 5 you wrote a class by hand: fields, a constructor that copies parameters into them, maybe an overridden ToString, and - if you wanted two objects with the same data to count as equal - a pile of equality code you didn't even attempt. A lot of typing for a simple idea: "a little bundle of values."
Modern C# noticed, growing features whose whole purpose is to delete boilerplate while making code safer. The frame is the same throughout: here's the old verbose way, and here's the new concise way the compiler now writes for you.
The two headliners - records and pattern matching - pair up beautifully. Records let you define small immutable data types in a line; pattern matching lets you ask questions about that data - "what shape is this? what's inside it?" - without a ladder of if/else. Together they're C#'s answer to modeling "this value is one of a fixed set of possibilities" cleanly.
Records - immutable data types in one line
📝 Record - a type (class or struct) declared with the record keyword, designed for holding data. From a single line, the compiler generates the constructor, the properties, value-based Equals and GetHashCode, a readable ToString, and deconstruction support. You write the intent; the compiler writes the ceremony.
Remember the Account == gotcha from Phase 5? Two class objects with identical data were not equal, because classes compare by reference rather than by value. Records flip that default - built for exactly the "a point is its X and Y, nothing more" value that should compare by contents.
The old way - a hand-written class for a simple point:
class PointClass
{
public int X { get; }
public int Y { get; }
public PointClass(int x, int y)
{
X = x;
Y = y;
}
public override string ToString() => $"PointClass {{ X = {X}, Y = {Y} }}";
public override bool Equals(object? obj) =>
obj is PointClass p && p.X == X && p.Y == Y;
public override int GetHashCode() => HashCode.Combine(X, Y);
}
What just happened: Roughly twenty lines to say "a point is two ints, and two points with the same numbers are equal." The constructor copies parameters into properties, ToString builds a readable string, and Equals/GetHashCode do the value comparison by hand - easy to get subtly wrong (forget a field and equality silently breaks).
Now watch it collapse:
record Point(int X, int Y);
What just happened: That one line - a positional record - generates all of the above. (int X, int Y) is the primary constructor: it declares two init-only properties X and Y and a constructor that fills them. Value equality, a tidy ToString, and deconstruction, for free. Twenty lines became one, and the generated version can't drift out of sync the way hand-written equality does.
var a = new Point(1, 2);
var b = new Point(1, 2);
Console.WriteLine(a); // ToString, generated
Console.WriteLine(a == b); // value equality, generated
Console.WriteLine(a.X); // property, generated
var (x, y) = a; // deconstruction, generated
Console.WriteLine($"x={x}, y={y}");
Point { X = 1, Y = 2 }
True
1
x=1, y=2
What just happened: a == b is True - the opposite of the class behavior in Phase 5 - because records compare by contents. Console.WriteLine(a) printed a readable summary instead of the type name, and var (x, y) = a pulled the two values straight out (deconstruction), no code written.
Non-destructive mutation with with. Records are immutable by default - those generated properties are init-only, so you can't change a Point after building it. For "the same thing, with one field different," use the with expression: it makes a copy, changing only what you name, leaving the original untouched.
var p1 = new Point(1, 2);
var p2 = p1 with { Y = 99 }; // a copy of p1, but Y is 99
Console.WriteLine(p1); // unchanged
Console.WriteLine(p2); // the modified copy
Point { X = 1, Y = 2 }
Point { X = 1, Y = 99 }
What just happened: p1 with { Y = 99 } produced a brand-new Point carrying p1's X and an overridden Y. p1 itself never changed - non-destructive mutation: the convenience of "tweak this value" without the bugs of objects mutating under you while other code holds a reference.
💡 When to reach for a record. Use records for data defined by its values - DTOs, value objects (Money, Coordinate, DateRange), events, query results. Use a regular class for things with identity and behavior that mutate over their lifetime - a ShoppingCart, a database connection, a game's Player. Quick test: if "two of these are the same when their contents match," it wants to be a record.
📝 record struct. record defaults to a reference type (a class). Write record struct Point(int X, int Y); for the same conveniences on a value type - copied on assignment, lives on the stack when local, no heap allocation. One difference to know: a positional record struct's properties are mutable (get; set;) by default, so it is not immutable like a record class; write readonly record struct Point(int X, int Y); if you want the same init-only immutability. Reach for it for tiny, short-lived values to avoid heap pressure; plain record is the right default until you've measured a reason to switch.
init and required members - immutability without a giant constructor
Records bake in init-only properties, but you can use the same tools on any class. You met init briefly in Phase 5 - here's why it matters.
📝 init accessor - like set, but it can only run during object construction (in a constructor or an object initializer); after that, the property is frozen. required member - a property the compiler forces the caller to set when creating the object, or the code won't compile.
The old tension: object initializers (new Thing { A = 1, B = 2 }) read beautifully, but a plain set property leaves the object mutable forever, and nothing stops a caller forgetting a field. init + required resolve both - initializer syntax, guaranteed-set fields, immutability afterward.
class Config
{
public required string Host { get; init; } // must be set; frozen after
public int Port { get; init; } = 8080; // optional, with a default
}
var c = new Config { Host = "localhost", Port = 5432 };
Console.WriteLine($"{c.Host}:{c.Port}");
// c.Host = "other"; // compile error: init-only, can't assign after construction
// var bad = new Config { Port = 1 }; // compile error: required 'Host' not set
localhost:5432
What just happened: required string Host made the compiler refuse any new Config { ... } that doesn't set Host - a compile error instead of a runtime null. Both properties are init, so once c is built its values are locked - readability of object initializers plus safety of immutability, no sprawling constructor.
Pattern matching - asking "what shape is this data?"
Pattern matching is C#'s way of testing a value's shape - its type, contents, structure - and pulling pieces out of it in one concise expression. It replaces tall stacks of if (x is SomeType) { var y = (SomeType)x; if (y.Prop > 5) ... }.
The is pattern. The simplest form tests a type and binds a variable in one move:
object o = "hello";
if (o is string s) // is it a string? if so, call it s
Console.WriteLine(s.Length); // s is already typed as string here
5
What just happened: o is string s did two jobs at once: checked whether o is a string, and if so assigned it to a properly-typed variable s you can use immediately - no separate cast, no second declaration. The old way was a type check followed by an explicit (string)o cast; this fuses them.
switch expressions with patterns. The real power shows up in the switch expression (produces a value, distinct from the older switch statement): it matches a value against a series of patterns and returns the arm that fits.
static string Classify(object value) => value switch
{
int n when n < 0 => "negative int",
int n => $"int: {n}", // type pattern, binds n
string { Length: 0 } => "empty string", // property pattern
string s => $"string of {s.Length}",
null => "null",
_ => "something else" // _ is the catch-all
};
Console.WriteLine(Classify(42));
Console.WriteLine(Classify(-3));
Console.WriteLine(Classify("hi"));
Console.WriteLine(Classify(""));
Console.WriteLine(Classify(null));
Console.WriteLine(Classify(3.14));
int: 42
negative int
string of 2
empty string
null
something else
What just happened: The switch expression tested value top to bottom and returned the first matching arm. int n is a type pattern binding the matched value to n. when n < 0 adds a guard - an extra condition. string { Length: 0 } is a property pattern: matches a string and checks its Length is 0. _ is the discard, the catch-all. Compare this to a nest of if/else if with manual casts: same logic, a fraction of the noise, and the compiler warns if you've left a case unhandled.
Relational and logical patterns. Inside a pattern you can use <, >, <=, >= and combine patterns with and, or, not - reading like the math you'd say out loud:
static string Grade(int score) => score switch
{
< 0 or > 100 => "invalid",
>= 90 => "A",
>= 80 => "B",
>= 70 => "C",
_ => "F"
};
Console.WriteLine(Grade(95));
Console.WriteLine(Grade(72));
Console.WriteLine(Grade(150));
A
C
invalid
What just happened: < 0 or > 100 is a logical pattern combining two relational patterns. The arms below lean on order: once >= 90 fails, >= 80 only sees scores under 90, so you don't repeat the upper bound - clearer than if (score < 0 || score > 100) chains.
Property and positional patterns - matching deep into data. Property patterns shine on records; you can match nested fields, and because records support deconstruction you can also use positional patterns that match by position:
record Person(string Name, int Age);
static string Describe(Person p) => p switch
{
{ Age: > 64 } => $"{p.Name} is a senior",
{ Age: >= 18 and < 65 } => $"{p.Name} is an adult",
("Sam", _) => "Hi Sam, whatever your age", // positional pattern
{ Age: < 0 } => "invalid age",
_ => $"{p.Name} is a minor"
};
Console.WriteLine(Describe(new Person("Ada", 70)));
Console.WriteLine(Describe(new Person("Bo", 30)));
Console.WriteLine(Describe(new Person("Sam", 5)));
Console.WriteLine(Describe(new Person("Kit", 10)));
Ada is a senior
Bo is an adult
Hi Sam, whatever your age
Kit is a minor
What just happened: { Age: > 64 } is a property pattern reaching into the record's Age. { Age: >= 18 and < 65 } combines a property pattern with a logical-relational pattern. ("Sam", _) is a positional pattern: it deconstructs the Person by position (Name, Age), matching when the first slot is "Sam" and the second is anything (_).
💡 Why this pairing matters. Records + pattern matching is how C# models "this value is one of a fixed set of cases" - a shape that's an Empty, a Circle, or a Rectangle; a result that's a Success or a Failure. Define the cases as records, then switch over them with type and property patterns; the compiler can tell you when you've forgotten a case.
Nullable reference types - the compiler hunts your null bugs
📝 Nullable reference types (NRT) - a compiler feature (on by default in new projects via <Nullable>enable</Nullable> in the .csproj) that splits reference types into two: string means "never null," string? means "might be null." The compiler then warns you wherever you might dereference something that could be null.
You met this in Phase 2. The single most common crash in C# history is the NullReferenceException - calling .Length or .Name on something that turned out to be null. NRT's goal: move that discovery from 3 a.m. in production to right now, as a squiggle in your editor.
// With <Nullable>enable</Nullable>:
string name = "Ada"; // non-null: the compiler guarantees it
string? maybe = null; // nullable: explicitly allowed to be null
Console.WriteLine(name.Length); // fine - name can't be null
// Console.WriteLine(maybe.Length); // WARNING: 'maybe' may be null here
if (maybe is not null) // once you check...
Console.WriteLine(maybe.Length); // ...the warning is gone - compiler knows it's safe
3
What just happened: string name is non-nullable, so the compiler lets you use name.Length freely. string? maybe is nullable, so dereferencing it without a check earns a compile-time warning. After if (maybe is not null), the compiler's flow analysis understands maybe can't be null inside that block, so the warning disappears.
The null-forgiving operator !. Occasionally you know more than the compiler - certain a value isn't null even though it can't prove it. The ! operator says "trust me, this isn't null" and silences the warning:
string? maybe = GetItMaybe();
string definitely = maybe!; // 'I promise this isn't null'
What just happened: maybe! tells the compiler to treat the value as non-null and drop the warning. ⚠️ Use this rarely, only when genuinely certain - it's a promise you make, not a check. If you're wrong, you get the very NullReferenceException the feature was meant to prevent, safety net deliberately switched off.
⚠️ NRT is compile-time analysis, not a runtime guarantee. string (non-nullable) is a promise the compiler tries to verify, not a wall enforced when the program runs. A null can still sneak in - from older code compiled without NRT, JSON deserialization, reflection, a library that ignores the annotations. NRT makes nulls visible and much rarer, not impossible - treat the warnings as an early-warning system, not a force field.
Other modern niceties - small wins that add up
A grab-bag of recent features that quietly trim noise from everyday code, together making modern C# read cleaner than a few years ago.
Target-typed new(). When the type is already obvious from the left side, you don't repeat it on the right:
// old: type written twice
List<string> names1 = new List<string>();
// new: the compiler infers it from the declared type
List<string> names2 = new();
Dictionary<string, int> counts = new();
Point origin = new(0, 0);
What just happened: new() fills in the type from the variable's declared type on the left - unambiguous, shorter, no List<string> echoed twice.
using declarations. Phase 11's using statement needed braces and an extra indent. A using declaration drops both - the resource is disposed automatically at the end of the enclosing scope:
// old: using statement, extra braces and nesting
using (var reader = new StreamReader("data.txt"))
{
Console.WriteLine(reader.ReadLine());
}
// new: using declaration - disposed at end of the method, no braces
using var reader2 = new StreamReader("data.txt");
Console.WriteLine(reader2.ReadLine());
What just happened: using var reader2 = ... schedules reader2.Dispose() for the end of the current scope, exactly like the block form, minus the braces and indentation.
File-scoped namespaces. Declare the namespace once with a semicolon and skip the wrapping braces that indented your whole file:
// old: braces wrap (and indent) the entire file
namespace MyApp
{
class Thing { }
}
// new: file-scoped - one line, no indentation tax
namespace MyApp;
class Thing { }
What just happened: namespace MyApp; applies to the entire file, so everything below sits at the left margin instead of indented inside braces - the modern default, since almost every file has exactly one namespace.
Top-level statements (recap). As seen in Phase 1, a simple program doesn't need class Program { static void Main() { ... } } scaffolding - write executable statements straight in Program.cs, and the compiler generates Main for you.
Recap
- Records (
record Point(int X, int Y);) generate the constructor, properties, value equality,ToString, and deconstruction from one line. They compare by contents (fixing the Phase 5==gotcha), are immutable by default, and supportwithfor non-destructive copies. Use for DTOs and value objects;record structfor tiny value-type versions. initandrequiredgive object-initializer syntax with immutability:initfreezes a property after construction,requiredforces callers to set it - "forgot a field" becomes a compile error.- Pattern matching -
ispatterns,switchexpressions, and type/property/relational/logical/positional patterns - tests a value's shape and extracts its parts declaratively, replacing tallif/cast ladders. - Nullable reference types split
string(non-null) fromstring?(nullable) and warn at compile time about possible null dereferences;!silences a warning when certain (use rarely). ⚠️ Compile-time analysis, not a runtime guarantee - nulls can still slip in. - Modern niceties - target-typed
new(),usingdeclarations, file-scoped namespaces, top-level statements - each delete a bit of repetition. - 💡 Records + pattern matching together model "one of a fixed set of cases" - define the cases as records,
switchover them by shape.
You can now write modern C# that's both shorter and safer than the verbose style - less boilerplate to maintain, more bugs caught before you run. Next: async/await and Tasks, the feature that lets your programs do many things at once without freezing.
Quick check
Test yourself on the two ideas that define this phase - records' value equality and pattern matching:
[
{
"q": "You define `record Point(int X, int Y);`, then create `var a = new Point(1, 2);` and `var b = new Point(1, 2);`. What does `a == b` return, and why?",
"choices": [
"True - records compare by value (their contents), so two points with the same X and Y are equal",
"False - like all C# types, == compares whether they're the same object in memory",
"A compile error, because you can't use == on a record",
"True, but only because they were created in the same method"
],
"answer": 0,
"explain": "Records generate value-based equality, so `a == b` is True when their contents match - the opposite of a plain class, where == compares object identity. This is exactly why records suit DTOs and value objects."
},
{
"q": "What does the pattern `{ Age: >= 18 and < 65 }` match in a switch expression over a record?",
"choices": [
"A value whose Age property is at least 18 and less than 65 (a property pattern combined with relational/logical patterns)",
"A value that equals the literal 18 or 65",
"Any record that has a property named Age, regardless of its value",
"A list of ages between 18 and 65"
],
"answer": 0,
"explain": "It's a property pattern (`{ Age: ... }`) reaching into the record's Age, combined with relational patterns (`>= 18`, `< 65`) joined by the logical pattern `and`. It matches when Age is in the range 18 to 64 inclusive."
},
{
"q": "Under `<Nullable>enable</Nullable>`, what does declaring a variable as `string` (no `?`) actually give you?",
"choices": [
"A compile-time promise the compiler tries to verify (warning you about possible null dereferences) - not a runtime guarantee that it can never be null",
"A runtime guarantee that the value can never, under any circumstances, be null",
"Exactly the same behavior as `string?` - the `?` is purely cosmetic",
"A value that automatically converts null into an empty string"
],
"answer": 0,
"explain": "Nullable reference types are compile-time flow analysis: `string` means 'intended non-null' and the compiler warns where a null might slip through. But it's not enforced at runtime - nulls can still arrive from deserialization, reflection, or older code, so a NullReferenceException remains possible."
}
]
Before the quiz: without looking back, say (or jot down) the core idea of this phase in your own words.
Check your understanding 3 questions
1. You define `record Point(int X, int Y);`, then create `var a = new Point(1, 2);` and `var b = new Point(1, 2);`. What does `a == b` return, and why?
2. What does the pattern `{ Age: >= 18 and < 65 }` match in a switch expression over a record?
3. Under `<Nullable>enable</Nullable>`, what does declaring a variable as `string` (no `?`) actually give you?