Skip to content

Pattern Matching & Records

Key Points

  • Pattern matching in C# has grown into a powerful sub-language: is patterns, switch expressions/statements, property patterns, list patterns, relational patterns, logical patterns.
  • switch expressions are exhaustive by default — compiler warns on missing arms (closed sets like enums or record class hierarchies).
  • Records add value-equality, with expressions, deconstruction, and primary constructors. Available as record class (default) or record 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; use var patterns 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 switch expression over the older switch statement 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

if (Compute() is var result && result > 0) Use(result);

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 / GetHashCode based on all fields/properties.
  • == / !=.
  • ToString showing properties.
  • Deconstruct for tuple-style unpack.
  • A clone method backing with expressions.

with expressions

var b = a with { Age = 37 };   // shallow copy of a, with Age changed

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

public string Describe(int n) => n switch
{
    < 0 => "neg",
    0   => "zero",
    > 0 => "pos"
};

❌ Wrong: deep if/else for object shape checks

if (obj is Customer c)
{
    if (c.IsActive)
    {
        if (c.Age >= 18) Process(c);
    }
}

✅ Correct: property pattern

if (obj is Customer { IsActive: true, Age: >= 18 } c)
    Process(c);

❌ 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

public record Customer
{
    public string Name { get; init; } = "";
}

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 switch expressions 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:

public record Tag { private readonly Guid _id = Guid.NewGuid(); }

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.
  • ⚠️ with in 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.
  • ⚠️ is pattern with overly broad types — costly type checks; prefer specific.

Further reading