Pattern Matching & Records
Key Points
- Pattern matching in C# has grown into a powerful sub-language:
ispatterns,switchexpressions/statements, property patterns, list patterns, relational patterns, logical patterns. switchexpressions are exhaustive by default — compiler warns on missing arms (closed sets like enums orrecord classhierarchies).- Records add value-equality,
withexpressions, deconstruction, and primary constructors. Available asrecord class(default) orrecord struct. - Property patterns like
{ Name: "Ada", Age: > 18 }deconstruct objects in conditions — ideal for guard clauses. - List patterns (
[first, .. middle, last]) destructure arrays /Span<T>. - Discard
_matches anything; usevarpatterns to bind without conditioning.
Concepts (deep dive)
is patterns
if (obj is string s) ... // type pattern + binding
if (obj is string { Length: > 0 } s) ... // type + property
if (obj is null) ... // null check
if (obj is not null) ... // negated
if (n is > 0 and < 100) ... // logical and relational
if (kind is Kind.A or Kind.B) ... // logical or
if (obj is var x) ... // always true; binds x
Each pattern adds context-aware narrowing — flow analysis tracks the new type within the branch.
switch expressions
public string Describe(object obj) => obj switch
{
null => "(null)",
int i when i < 0 => $"negative int {i}",
int i => $"int {i}",
string s => $"string '{s}'",
Customer { IsActive: true } c => $"active customer {c.Name}",
_ => "unknown"
};
Each arm: pattern → expression. The compiler verifies exhaustiveness for closed types (sealed hierarchies, enums) and warns when arms are missing.
💡 Senior insight: prefer the
switchexpression over the olderswitchstatement in new code — it's an expression that returns a value, avoids fall-through bugs, and the compiler's exhaustiveness checks are stronger.
Property patterns
public bool Eligible(Customer c) => c is
{
IsActive: true,
Country: "US" or "CA",
Age: >= 18 and <= 65
};
Property pattern syntax destructures the object: each : is a sub-pattern. Combine with and/or/not.
Nested properties:
if (order is { Customer: { IsPremium: true }, Total: > 1000 })
/* premium high-value */;
// Or shorthand:
if (order is { Customer.IsPremium: true, Total: > 1000 })
/* same */;
List patterns
int[] arr = [1, 2, 3, 4, 5];
bool startsWithOne = arr is [1, ..];
bool endsWithFive = arr is [.., 5];
bool exactly3 = arr is [_, _, _];
bool firstAndRest = arr is [int first, .. var rest];
Works on arrays, List<T>, Span<T>, ReadOnlySpan<T>. .. is "any number of elements"; bind with var rest to capture the slice.
Relational and logical patterns
public string Classify(int n) => n switch
{
< 0 => "negative",
0 => "zero",
> 0 and < 10 => "small positive",
>= 10 and < 100 => "medium",
_ => "large"
};
<, >, <=, >=, ==, != are relational. and, or, not are logical.
var pattern
Always matches; binds. Often used to introduce a name in a complex condition.
Records — value-equality reference types
public record Customer(string Name, int Age);
var a = new Customer("Ada", 36);
var b = new Customer("Ada", 36);
Console.WriteLine(a == b); // True — value equality
Console.WriteLine(a is Customer { Name: "Ada" }); // True
record (synonym record class) generates:
- Public init-only properties from primary constructor parameters.
Equals/GetHashCodebased on all fields/properties.==/!=.ToStringshowing properties.Deconstructfor tuple-style unpack.- A clone method backing
withexpressions.
with expressions
Each with allocates a new instance. For hot paths, hand-roll. For DTOs / config / value objects, with is the cleanest C# idiom.
record struct
public readonly record struct Point(int X, int Y);
var p = new Point(3, 4);
var q = p with { X = 5 };
Console.WriteLine(p == q); // False
Value type with record perks. Add readonly for full immutability. Best for small "value-like" things (Money, Coordinates, IDs).
Records and inheritance
public record Animal(string Name);
public record Dog(string Name, string Breed) : Animal(Name);
var d = new Dog("Rex", "Lab");
var a = (Animal)d;
Console.WriteLine(a == d); // True — value-equality respects derived type
Record inheritance is supported; equality is per-runtime-type to avoid Liskov violations.
Switch on records
public abstract record Shape;
public record Circle(double Radius) : Shape;
public record Rectangle(double W, double H) : Shape;
public double Area(Shape s) => s switch
{
Circle { Radius: var r } => Math.PI * r * r,
Rectangle (var w, var h) => w * h,
_ => 0
};
Combine type + property patterns + positional patterns. Sealed hierarchies + switch expressions = exhaustive matching with compile-time checks.
💡 Senior insight: make your record hierarchy
sealed(or use[GeneratedRegex]-style closed types). The compiler can then verify exhaustive switch — flagging missing arms when you add a new record.
Discriminated union pattern
C# doesn't have first-class discriminated unions, but you can simulate them with a sealed record hierarchy plus exhaustive switch:
public abstract record Result;
public sealed record Ok(int Value) : Result;
public sealed record Err(string Message) : Result;
public string Describe(Result r) => r switch
{
Ok(var v) => $"ok {v}",
Err(var msg) => $"error: {msg}"
// No _ — compiler warns if a new derived record is added
};
The C# language team is working on first-class unions; until then, this is the pattern.
Records with init-only properties
public record Config
{
public required string Endpoint { get; init; }
public string? UserAgent { get; init; }
}
var c = new Config { Endpoint = "https://...", UserAgent = "ua/1" };
record with explicit properties — primary constructor optional. required (C# 11+) ensures initialization.
How it works under the hood
switch expressions compile to balanced if/else decision trees, with the compiler choosing tests for efficiency (e.g., type tests bucketed first). Pattern matching with sealed hierarchies emits direct type checks — fast.
Records lower to a class (or struct) with auto-generated Equals, GetHashCode, ToString, Deconstruct, and a copy constructor + <Clone>$ method. The with expression invokes <Clone>$ and applies the property changes.
Property patterns lower to property reads + sub-pattern checks. List patterns lower to length checks + element accesses; for Span<T>, the compiler uses Span indexers.
Logical patterns short-circuit just like && / ||. Relational patterns compile to direct comparisons.
Code: correct vs wrong
❌ Wrong: switch statement when expression fits
public string Describe(int n)
{
switch (n)
{
case < 0: return "neg";
case 0: return "zero";
case > 0: return "pos";
default: return "?";
}
}
✅ Correct: switch expression
❌ Wrong: deep if/else for object shape checks
✅ Correct: property pattern
❌ Wrong: forgetting equality on derived records
public record Animal(string Name);
public record Dog(string Name) : Animal(Name);
Dog d = new("Rex");
Animal a = d;
Console.WriteLine(a.Equals(new Animal("Rex"))); // False — runtime types differ
(This is actually correct behavior — but if you're comparing across the hierarchy, expect type-strict equality.)
❌ Wrong: mutable record properties
public record Customer
{
public string Name { get; set; } = ""; // ❌ mutable; breaks value-equality assumptions
}
✅ Correct: init-only
Design patterns for this topic
Pattern 1 — "Sealed record hierarchy + switch expression = discriminated union"
- Intent: type-safe sum types.
- Code sketch: see "discriminated union pattern" above.
Pattern 2 — "Property pattern in guard clauses"
- Intent: condense complex shape checks.
- Code sketch:
public Result Validate(Order o) => o switch
{
{ Items.Count: 0 } => Result.Fail("empty"),
{ Total: <= 0 } => Result.Fail("zero total"),
{ Customer.IsBlocked: true } => Result.Fail("blocked"),
_ => Result.Ok()
};
Pattern 3 — "List pattern for parsing"
- Intent: destructure arrays / spans.
- Code sketch:
public string Format(int[] parts) => parts switch
{
[] => "(empty)",
[var only] => $"single {only}",
[var first, .. var rest] => $"first {first}, more: {rest.Length}"
};
Pattern 4 — "record class for DTOs, record struct for value-types"
- Intent: match storage to semantics.
- Code sketch:
public record CustomerDto(Guid Id, string Name);
public readonly record struct ProductId(Guid Value);
Pattern 5 — "with expressions for state transitions"
- Intent: immutable state machines.
- Code sketch:
public record OrderState(Status Current);
OrderState next = state with { Current = Status.Confirmed };
Pros & cons / trade-offs
| Feature | Pros | Cons |
|---|---|---|
switch expression | Concise; exhaustive; expression-shaped | Less power than statement (no statements in arm) |
| Property patterns | Compact deconstruction | Long patterns can become unreadable |
| List patterns | Slick array destructure | Allocations if rest-binding |
| Records | Value-equality + concise | with allocates per call |
| Sealed hierarchies | Exhaustive checks | Less extensible |
When to use / when to avoid
- Use
switchexpressions for any function that returns a value based on a discriminant. - Use property patterns for compound conditions on object shape.
- Use records for DTOs and value-objects.
- Use sealed record hierarchies for closed-set "discriminated unions".
- Avoid mutable records — defeats value semantics.
- Avoid deep nested patterns — at some point, just write the conditions clearly.
Interview Q&A
Q1. Difference between switch expression and switch statement? The expression returns a value; the statement returns nothing. Expressions can't have break / goto case / multiple statements per arm. The compiler enforces exhaustiveness in expressions; in statements, the default is more lax.
Q2. What's a property pattern? Pattern syntax that deconstructs an object's properties: obj is { Name: "Ada", Age: > 18 }. Combines type check with property checks.
Q3. How does with work on a record? The compiler generates a copy constructor + a <Clone>$ method. record with { X = newX } calls <Clone>$ then init sets X. New instance per call.
Q4. What does record struct differ from record class? The first is a value type; the second a reference type. Both get value-equality, with, etc. record struct doesn't allocate per instance but copies on assignment.
Q5. How is record equality computed? Compiler-generated Equals compares all instance fields. The synthesized GetHashCode combines them. Inheritance respects runtime type — derived records compare as not-equal to base records with same data.
Q6. What's a list pattern? Pattern for arrays / Span<T> / List<T>: [first, .. middle, last] destructures. .. matches any count; bind with var rest.
Q7. Why is or in a pattern or, not ||? or/and/not are pattern combinators (operate at the pattern level). ||/&& are boolean operators on the resulting boolean expressions. Different precedence, different semantics.
Q8. How does the compiler verify exhaustiveness in a switch expression? For closed types (sealed classes, enums, sealed record hierarchies, plus the special "object" exhaustiveness rules for primitives + null + _), the compiler walks all possible cases and emits a warning if any are unreachable from the patterns.
Q9. When does a record's with allocate? Always — each with call. The clone method allocates a new instance and copies fields.
Q10. Can you have a record with no public properties? Yes — write it like a class:
But it loses the value-equality benefit unless you implement Equals.
Q11. How does pattern matching interact with NRT? obj is string s narrows s to non-null string. obj is { } notNull narrows out null even for unconstrained generics. Flow analysis takes the narrowing into account.
Q12. What's the recommended way to model "either Ok(value) or Err(message)" in C#? Sealed record hierarchy + switch expression. C# is moving toward first-class unions; until then, this is the idiom.
Gotchas / common mistakes
- ⚠️ Non-sealed record hierarchy — exhaustiveness check doesn't fire.
- ⚠️ Mutable record properties — value-equality becomes lie.
- ⚠️
within hot path — allocates per call; profile. - ⚠️ Deep nested patterns — can hurt readability; balance.
- ⚠️ Forgetting
_in switch expression — runtime exception if no arm matches and there's no discard. - ⚠️
ispattern with overly broad types — costly type checks; prefer specific.