Skip to content

~ / blog / csharp-pattern-matching

C# Pattern Matching: switch Expressions and the End of if-else Chains

C# 10 min read

The polymorphism post said a chain of type checks is usually a missing virtual method. That is true when you own the types. When you do not (when the shapes come from an API, a parser, a message queue, or a Result type), you need to branch on structure, and C# has spent five versions building the best syntax in any C-family language for doing it. This post covers all of it, from is to list patterns.

Where it started: is with a variable

Before C# 7, testing and casting was three lines and a repeated cast:

if (item is EBook)
{
    var ebook = (EBook)item;
    Console.WriteLine(ebook.DownloadUrl);
}

The type pattern collapsed it:

if (item is EBook ebook)
    Console.WriteLine(ebook.DownloadUrl);

One test, one cast, one new variable scoped to where it is valid. Everything since is a generalisation of that idea: a pattern both tests a value and extracts from it.

The immediately useful relatives:

if (obj is null) { }                    // constant pattern
if (obj is not null) { }                // negated, better than != null, see below
if (book is { Price: > 20 }) { }        // property pattern
if (price is > 0 and < 100) { }         // relational + logical patterns

is not null is worth adopting as a habit. Unlike != null, it cannot be intercepted by an overloaded != operator, so it always means what it says.

The switch expression

The classic switch statement is a control-flow construct: cases, break, and assignment to a variable declared beforehand. The switch expression (C# 8) produces a value instead:

var shipping = item switch
{
    EBook            => 0,
    Hardback         => 6,
    Book             => 3,
    _                => throw new ArgumentException("Unknown item")
};

The differences from the statement form are all improvements:

  • It is an expression, so it can be assigned, returned, or passed as an argument directly.
  • => instead of case/break. No fall-through, so no forgotten break.
  • _ is the discard, the catch-all arm.
  • Arms are evaluated in order, top to bottom, first match wins. Order matters: put Book above EBook and the derived arms become unreachable: the compiler will tell you.
  • It must produce a value. If nothing matches and there is no _, it throws SwitchExpressionException, and the compiler warns you when it cannot prove the arms are exhaustive.

Compare with the old form and the improvement is obvious:

int shipping;
switch (item)
{
    case EBook _:    shipping = 0; break;
    case Hardback _: shipping = 6; break;
    case Book _:     shipping = 3; break;
    default: throw new ArgumentException("Unknown item");
}

Property patterns

This is where pattern matching starts doing things if chains do badly: matching on shape and contents at once.

var discount = book switch
{
    { Price: > 50, Author: "Orwell" } => 0.30m,
    { Price: > 50 }                   => 0.20m,
    { Price: > 20 }                   => 0.10m,
    _                                 => 0m
};

Patterns nest, which is the part that pays off with real object graphs:

var status = order switch
{
    { Customer: { Tier: "Gold" }, Total: > 100 } => "free shipping",
    { Customer.Tier: "Gold" }                    => "gold rate",       // shorter nested form
    { Items.Count: 0 }                           => "empty order",
    null                                         => "no order",
    _                                            => "standard"
};

{ Customer.Tier: "Gold" } is the C# 10 extended property pattern, and note it is null-safe: if Customer is null the pattern simply does not match, rather than throwing. Writing that as an if requires explicit null checks at every level.

Type patterns with conditions

Combine a type pattern with a when clause, or with a nested property pattern:

var label = item switch
{
    EBook { SizeMb: > 100 } e  => $"Large ebook, {e.SizeMb}MB",
    EBook e                    => $"Ebook: {e.Title}",
    Hardback h when h.IsSigned => $"Signed: {h.Title}",
    Book b                     => b.Title,
    null                       => "nothing",
    _                          => "unknown"
};

Use a property pattern when the condition is about the object’s own members, and when when it needs a method call or involves outside state. Both read better than the equivalent nested if.

Relational, logical and list patterns

Relational and logical patterns (C# 9) let you express ranges without repeating the subject:

var band = price switch
{
    < 0            => "invalid",
    0              => "free",
    > 0 and < 10   => "cheap",
    >= 10 and < 30 => "standard",
    _              => "premium"
};

and, or and not combine patterns, not booleans. That distinction lets you write is not null and not "" or is 'a' or 'e' or 'i' or 'o' or 'u' without naming the variable each time.

Tuple patterns match several values at once, the cleanest way to express a small decision table:

var rate = (customerTier, orderTotal) switch
{
    ("Gold",   > 100) => 0m,
    ("Gold",   _    ) => 2.50m,
    ("Silver", > 200) => 0m,
    (_,        > 500) => 0m,
    _                 => 4.99m
};

List patterns (C# 11) match the structure of a sequence:

var describe = numbers switch
{
    []              => "empty",
    [var single]    => $"one item: {single}",
    [var first, .. ] => $"starts with {first}",
};

if (segments is ["api", "v1", var resource, ..])   // slice pattern
    Console.WriteLine($"resource: {resource}");

That last one is genuinely useful for routing, command parsing and file-path handling, and it replaces a pile of Length checks and index arithmetic.

Exhaustiveness, and why it matters

The compiler tries to prove that a switch expression handles every possible input. When it cannot, you get “the switch expression does not handle all possible values”. That warning is the feature.

It becomes a real safety net when the input is a closed set:

public enum OrderState { Pending, Shipped, Delivered, Cancelled }

var message = state switch
{
    OrderState.Pending   => "Preparing",
    OrderState.Shipped   => "On its way",
    OrderState.Delivered => "Delivered",
    OrderState.Cancelled => "Cancelled"
};

Add a fifth enum member next year and every switch expression like this produces a warning pointing at the code that needs updating. Add a _ => "unknown" arm and you lose that entirely: the compiler can no longer tell you what you forgot. So: omit the discard for closed sets you own, and include it when input genuinely can be anything.

(A caveat worth knowing: an enum in C# can hold any integer value, so the compiler still warns unless you cover the unnamed cases. Treat the warning as a prompt, not a nuisance.)

When not to use it

Pattern matching is a tool for branching on data you do not control. Two limits:

It is not a replacement for polymorphism. If you own EBook and Hardback, GetShippingCost() as a virtual method beats a switch, because adding AudioBook requires no edit anywhere. Use a type switch when the types come from elsewhere, when the logic does not belong on the type (formatting, serialisation, mapping), or when it varies per consumer.

Deeply nested patterns get unreadable. A three-level property pattern with when clauses is clever and hard to review. When an arm needs explaining, extract a named method and call it from the arm.

The same idea inside Scala, Rust and Swift

C# imported pattern matching from the ML/functional tradition, and the family resemblance is strong.

Rust. match is exhaustive by requirement, not by warning: a non-exhaustive match is a compile error. Combined with enums that carry data, it is the model C# is steadily moving towards:

match item {
    Item::EBook { size_mb } if size_mb > 100 => "large ebook",
    Item::EBook { .. }                        => "ebook",
    Item::Hardback                            => "hardback",
}

Scala. case class plus match is where records-plus-switch-expressions came from, extractors and all. C#‘s deconstruction patterns are Scala’s unapply with different spelling.

Swift (Apple). switch is exhaustive and required to be, and supports where clauses, tuple matching and range matching, nearly the same feature set:

switch (tier, total) {
case ("Gold", let t) where t > 100: return 0
default: return 4.99
}

Kotlin (Google, Android). when is the expression form, with smart casts that make the is pattern’s variable implicit:

val cost = when (item) {
    is EBook -> 0
    is Hardback -> 6
    else -> 3
}

Java (Netflix). Arrived late but arrived: pattern matching for switch and record patterns are now standard, driven by the same sealed-hierarchy plus records combination.

TypeScript (Meta). No match yet, but discriminated unions plus a switch on a kind field, with the compiler checking exhaustiveness via never, occupy the same space. The convergence is total: every modern language decided that branching on the shape of data deserves first-class syntax.

Summary

  • A pattern tests a value and extracts from it in one step. if (item is EBook ebook) is the smallest example.
  • A switch expression produces a value, uses => arms, has no fall-through, evaluates top to bottom, and needs _ for a catch-all.
  • Property patterns match on contents and shape, nest freely, and are null-safe at every level.
  • when clauses handle conditions that patterns cannot express, such as method calls or external state.
  • Relational (> 10) and logical (and, or, not) patterns compose patterns, not booleans.
  • Tuple patterns express small decision tables; list patterns match sequence structure including slices.
  • Exhaustiveness checking is the safety net: omit the discard on closed sets so the compiler tells you what you missed when the set grows.
  • Prefer a virtual method when you own the types; use pattern matching for types and shapes you do not control.

Interview questions and answers

1. What is pattern matching in C#?

A set of language features that test a value’s type, shape or contents and simultaneously extract data from it, using the is operator, switch expressions and switch statements. It replaces sequences of type checks, casts, null checks and property comparisons with a single declarative expression.

2. What is the difference between a switch statement and a switch expression?

A switch statement is control flow: cases, break and side effects. A switch expression evaluates to a value, uses => arms separated by commas, has no fall-through, requires every arm to produce a compatible type, and can be assigned or returned directly. Switch expressions are also checked for exhaustiveness.

3. What happens if no arm of a switch expression matches?

It throws SwitchExpressionException at runtime. The compiler warns at build time when it cannot prove all inputs are handled, which is why adding _ silences the warning, and why leaving _ off is deliberate when the input set is closed and you want future additions flagged.

4. What is a property pattern?

A pattern that matches against the values of an object’s members, written in braces: book is { Price: > 20, Author: "Orwell" }. Patterns can nest, including the shorthand { Customer.Tier: "Gold" }, and the whole pattern simply fails to match rather than throwing if any part of the chain is null.

5. What is the difference between a property pattern and a when clause?

A property pattern matches on member values and participates in the compiler’s analysis of the switch. A when clause attaches an arbitrary boolean expression to an arm, so it can call methods or use variables outside the matched object. Prefer the property pattern where it suffices; use when for logic patterns cannot express.

6. What are relational and logical patterns?

Relational patterns compare against a constant: > 10, <= 0. Logical patterns combine patterns with and, or and not: is > 0 and < 100, is not null. They operate on patterns rather than boolean values, so the subject does not need repeating in each clause.

7. Why is is not null preferred to != null?

Because != can be overloaded by the type, so x != null may run user code with different semantics, or throw. is not null is a pattern evaluated by the runtime against the reference itself, so it always means exactly what it says and cannot be intercepted.

8. What are list patterns?

Patterns that match the structure of a sequence: [] for empty, [var x] for exactly one element, [first, .., last] using the slice pattern .. to match any number in between. They apply to arrays, lists and anything indexable with a length, and are useful for parsing command arguments and URL segments.

9. When should you use pattern matching instead of polymorphism?

When you do not own the types, when they come from an external system or a library, or when the behaviour does not belong on the type: mapping, formatting, serialisation, or logic that differs per consumer. If you own the hierarchy and the behaviour is intrinsic to it, a virtual method is better because new subclasses need no changes at call sites.

10. How does exhaustiveness checking help?

The compiler analyses which inputs the arms cover and warns when the switch expression is not exhaustive. With a closed set such as an enum or a sealed hierarchy and no discard arm, adding a new member produces warnings at exactly the places that need updating, turning a runtime bug into a build-time list of work.

# tags csharppattern-matchingswitch-expressionsdotnetinterview-questions
$ share linkedin x / twitter

Tayyab Shahid, Senior Software Engineer in London

Tayyab Shahid

Senior Software Engineer & .NET Consultant · London, UK

about github linkedin

§ related posts

Building something like this?

I take on freelance .NET projects, consulting, and senior roles.

get in touch

← cd ~/blog

CV