Modern C#: Records, Pattern Matching and Nullable Reference Types
Write idiomatic modern C#: records and value equality, with-expressions, pattern matching from property to list patterns, nullable reference types, required members, primary constructors and collection expressions.
🎯 What you'll learn
- Records, record structs and with-expressions
- Switch expressions and every kind of pattern
- Nullable reference types in practice
- Required members, primary constructors and collection expressions
📚 Before you start
1Records
Records are types with value-based equality, built-in ToString, deconstruction and non-destructive mutation with with:
public record Money(decimal Amount, string Currency); // reference type, immutable positional properties
public readonly record struct Point(int X, int Y); // value type
var price = new Money(10m, "EUR");
var discounted = price with { Amount = 8m }; // copy with one change
Console.WriteLine(price == new Money(10m, "EUR")); // True: compares valuesUse records for DTOs, events, value objects and messages. Classes remain right for entities with identity and behaviour.
2Pattern Matching
string Describe(Shape s) => s switch
{
Circle { Radius: 0 } => "point",
Circle c => $"circle r={c.Radius}",
Rectangle { Width: var w, Height: var h } when w == h => $"square {w}",
Rectangle r => $"rect {r.Width}x{r.Height}",
null => "nothing",
_ => "unknown shape",
};
string Grade(int score) => score switch { >= 90 => "A", >= 80 and < 90 => "B", < 0 or > 100 => "invalid", _ => "C" };
string Summarize(int[] xs) => xs switch { [] => "empty", [var only] => $"one: {only}", [var first, .., var last] => $"{first}..{last}" };Type patterns, property patterns, relational (>=), logical (and, or, not), positional patterns (deconstruction) and list patterns (C# 11) make branching declarative, and the compiler warns when a switch expression isn't exhaustive.
3Nullable Reference Types
With <Nullable>enable</Nullable>, string means "never null" and string? means "may be null". The compiler tracks null-state through your code and warns on possible null dereferences or on assigning null to non-nullable types.
- Check with
is null/is not null, use?.and??,??=. - Attributes like
[NotNullWhen(true)]teach the compiler about your helper methods (e.g.TryGet). - The null-forgiving operator
!silences a warning: use rarely, as a statement that you know better. - Treat nullable warnings as errors in new code (
<WarningsAsErrors>nullable</WarningsAsErrors>).
4Other Features Worth Knowing
requiredmembers andinitsetters: objects must be fully initialized at creation, yet immutable afterwards.- Primary constructors on classes (C# 12):
class OrderService(IOrderRepository repo, ILogger<OrderService> log). - Collection expressions (C# 12):
int[] xs = [1, 2, 3];,List<string> names = [..first, ..second];. - File-scoped namespaces, global usings, raw string literals (
"""…"""),switchon tuples. - Static abstract interface members enable generic math (
INumber<T>).
⚠️ Common Pitfalls
- Records with mutable collections: equality compares the collection reference, not contents.
- Using records for EF entities with value equality: identity semantics get confusing.
- Non-exhaustive switch expressions that throw
SwitchExpressionExceptionat runtime. - Sprinkling
!to silence nullable warnings instead of fixing the flow. - Overusing clever patterns where a simple
ifreads better.
📋 Quick Reference
record = value equality + with + ToString · record struct for small values · classes for entities
switch expressions: type, property, relational, logical, positional, list patterns · exhaustive
Nullable enabled: string vs string? · is null · ?. ?? ??= · [NotNullWhen] · avoid !
required/init · primary constructors · collection expressions [..a, ..b] · raw strings🧠 Key takeaways
- Records give value equality and with; use them for DTOs, messages and value objects.
- Switch expressions with property, relational, logical and list patterns replace if-chains.
- Enable nullable reference types and fix warnings instead of sprinkling
!. - Use required/init, primary constructors and collection expressions for concise, safe code.
🎤 Interview Q&A
Q1When would you use a record instead of a class?core▾
For immutable data defined by its values: DTOs, API contracts, events/messages, value objects. Records give value equality, with-expressions, deconstruction and ToString. Use classes for entities with identity and mutable behaviour.
- record class vs record struct? Reference vs value type; record struct for small values.
Q2What kinds of patterns does C# support?core▾
Type, constant, property, positional (deconstruction), relational (>), logical (and/or/not), var, discard, and list patterns ([first, .., last]), usable in is, switch statements and switch expressions with when guards.
- Exhaustiveness? The compiler warns when a switch expression doesn't cover all inputs.
Q3How do nullable reference types work?core▾
A compile-time feature: with nullable enabled, reference types are non-null by default and T? marks possibly-null ones; flow analysis warns on possible null dereference. Nothing changes at runtime, so validate external input.
- [NotNullWhen(true)]? Tells the compiler an out value isn't null when the method returns true.
Q4What problems do required and init solve?senior▾
init allows setting properties only during object initialization (immutable afterwards); required forces callers to set them, so objects can't be created half-initialized without constructors for every combination.
- Works with records? Yes.
Q5What are primary constructors on classes?core▾
C# 12 lets classes declare constructor parameters in the type header; parameters are captured and usable throughout the class, reducing DI boilerplate. They aren't automatically readonly properties (unlike positional records).
- Gotcha? Parameters are mutable captured variables; assign to readonly fields if needed.
🛠️ Practice exercises
Each exercise is a file in this topic folder. Code exercises are self-checking: run them and make the checks pass.
✅ Check yourself
Answer the questions — feedback is instant.
new Money(10, "EUR") == new Money(10, "EUR") for a record?p with { X = 5 } do?string? mean?