Learnixo
Back to blog
Backend Systemsintermediate

Pattern Matching in C# — Switch Expressions, Property, List & Positional Patterns

Complete guide to C# pattern matching: type patterns, property patterns, positional patterns, list patterns, switch expressions, guards, and when each pattern eliminates conditional complexity.

Asma Hafeez KhanMay 26, 20269 min read
C#.NETPattern MatchingSwitch ExpressionsLanguage FeaturesC# 12
Share:𝕏

Pattern Matching in C# — Switch Expressions, Property, List & Positional Patterns

Pattern matching is one of the most interview-tested C# features from C# 8 onwards. It replaces chains of if/else if, is checks, and casting with a structured syntax that the compiler can exhaustiveness-check. Understanding when each pattern applies is the difference between idiomatic modern C# and old-style imperative branching.

What you'll learn:

  • Declaration and type patterns
  • Property and positional patterns
  • List patterns (C# 11)
  • Switch expressions and guards
  • Exhaustiveness and discard patterns
  • Real-world usage: discriminated unions, command routing, data transformation

Why Pattern Matching Exists

Before C# 8, type-based branching looked like this:

C#
// Old: cast, check, cast again
object shape = GetShape();
double area;
if (shape is Circle)
{
    var c = (Circle)shape;
    area = Math.PI * c.Radius * c.Radius;
}
else if (shape is Rectangle r)
{
    area = r.Width * r.Height;
}
else
{
    throw new ArgumentException("Unknown shape");
}

Pattern matching collapses this:

C#
double area = shape switch
{
    Circle c        => Math.PI * c.Radius * c.Radius,
    Rectangle r     => r.Width * r.Height,
    _               => throw new ArgumentException("Unknown shape"),
};

The compiler also warns if you miss a case in an exhaustive switch — something the old if/else if chain never gave you.


1. Declaration Pattern

Tests that a value is of a type and binds it to a name in one step.

C#
// is-pattern: declare and bind
object value = GetValue();

if (value is string s)
{
    Console.WriteLine(s.ToUpper());  // s is string here
}

if (value is int i && i > 0)
{
    Console.WriteLine($"Positive integer: {i}");
}

The is declaration pattern is the most common entry point — you've probably used it without thinking of it as "pattern matching."


2. Type Pattern in Switch

C#
public string DescribePayment(Payment payment) => payment switch
{
    CreditCardPayment cc  => $"Credit card ending {cc.Last4}",
    BankTransferPayment bt => $"Bank transfer from {bt.AccountNumber}",
    CryptoPayment crypto  => $"Crypto: {crypto.CoinSymbol}",
    _                     => "Unknown payment type",
};

The _ discard arm matches anything not matched above. Without it, a SwitchExpressionException is thrown at runtime for unmatched values — the compiler warns you if the switch might not be exhaustive.

Exhaustiveness with sealed hierarchies

C#
// Make the hierarchy sealed and the compiler can verify exhaustiveness
public abstract record Shape;
public sealed record Circle(double Radius) : Shape;
public sealed record Rectangle(double Width, double Height) : Shape;
public sealed record Triangle(double Base, double Height) : Shape;

// Now the compiler knows all possible Shape subtypes
public double Area(Shape shape) => shape switch
{
    Circle c    => Math.PI * c.Radius * c.Radius,
    Rectangle r => r.Width * r.Height,
    Triangle t  => 0.5 * t.Base * t.Height,
    // No _ needed — compiler verifies all sealed subtypes are covered
};

3. Property Pattern

Match on the values of properties inside an object.

C#
public record Order(string Status, decimal Total, bool IsPriority);

public decimal CalculateDiscount(Order order) => order switch
{
    { Status: "vip", Total: > 500 }         => order.Total * 0.20m,
    { Status: "vip" }                        => order.Total * 0.10m,
    { IsPriority: true, Total: > 1000 }     => order.Total * 0.05m,
    { Status: "new" }                        => 0m,
    _                                        => order.Total * 0.02m,
};

Properties can be nested:

C#
public record Address(string Country, string City);
public record Customer(string Name, Address Address, int LoyaltyYears);

public string GetShippingTier(Customer customer) => customer switch
{
    { Address: { Country: "NO" }, LoyaltyYears: >= 5 } => "priority-domestic",
    { Address: { Country: "NO" } }                      => "standard-domestic",
    { Address: { Country: "SE" or "DK" } }              => "nordic",
    _                                                    => "international",
};

4. Positional Pattern

Works with types that have a Deconstruct method — primarily records and tuples.

C#
public record Point(int X, int Y);

// Records auto-generate Deconstruct
string Quadrant(Point p) => p switch
{
    (0, 0)          => "origin",
    (> 0, > 0)      => "first quadrant",
    (< 0, > 0)      => "second quadrant",
    (< 0, < 0)      => "third quadrant",
    (> 0, < 0)      => "fourth quadrant",
    (0, _)          => "Y axis",
    (_, 0)          => "X axis",
};

With tuples directly (no record needed):

C#
string Classify(int x, int y) => (x, y) switch
{
    (0, 0)     => "origin",
    (> 0, > 0) => "Q1",
    (< 0, > 0) => "Q2",
    _          => "other",
};

Positional and property patterns compose:

C#
public record OrderLine(Product Product, int Quantity);
public record Product(string Category, decimal UnitPrice);

decimal LineTotal(OrderLine line) => line switch
{
    // Positional: destructure OrderLine, then use property pattern on Product
    ({ Category: "electronics" }, var qty) => line.Product.UnitPrice * qty * 0.9m,
    ({ Category: "books" }, var qty)       => line.Product.UnitPrice * qty,
    (var product, var qty)                 => product.UnitPrice * qty * 0.95m,
};

5. List Pattern (C# 11)

Match on the shape and content of arrays, spans, and any type implementing Length/Count and an indexer.

C#
int[] numbers = { 1, 2, 3, 4, 5 };

string Describe(int[] arr) => arr switch
{
    []              => "empty",
    [var single]    => $"single element: {single}",
    [var first, ..] => $"starts with {first}",
};

// Match specific structure
bool IsValidCommand(string[] args) => args switch
{
    ["run", var file]              => true,
    ["run", var file, "--verbose"] => true,
    ["build", ..]                  => true,
    _                              => false,
};

// Slice pattern (..) captures remaining elements
string SummarisePath(string[] segments) => segments switch
{
    [var root, .., var leaf] => $"{root}/.../{leaf}",
    [var only]               => only,
    []                       => "(empty)",
};

Practical example — parsing CSV header row:

C#
string[] headers = csv.Split(',');
var format = headers switch
{
    ["id", "name", "email", ..]              => CsvFormat.UserExport,
    ["order_id", "customer_id", "total", ..] => CsvFormat.OrderExport,
    ["sku", "quantity", ..]                  => CsvFormat.InventoryReport,
    _                                        => throw new FormatException($"Unknown CSV format: {string.Join(",", headers)}"),
};

6. Guards (When Clauses)

Add an extra condition to any pattern arm with when:

C#
public record Transaction(decimal Amount, string Currency, bool IsFlagged);

string ReviewStatus(Transaction tx) => tx switch
{
    { IsFlagged: true }                            => "manual-review",
    { Amount: > 10_000, Currency: "USD" }          => "large-usd-review",
    { Amount: var a } when a < 0                   => "refund",
    { Currency: var c } when IsUnusualCurrency(c)  => "currency-review",
    _                                              => "approved",
};

Guards let you call methods or do comparisons that patterns can't express. They run after the pattern matches, so the binding variables are available.


7. Relational and Logical Patterns

C#
// Relational: <, >, <=, >=
string Category(int score) => score switch
{
    >= 90 => "A",
    >= 80 => "B",
    >= 70 => "C",
    >= 60 => "D",
    _     => "F",
};

// Logical: and, or, not
bool IsWeekendHour(DayOfWeek day, int hour) =>
    (day, hour) switch
    {
        (DayOfWeek.Saturday or DayOfWeek.Sunday, >= 9 and <= 21) => true,
        _ => false,
    };

// 'not' pattern
if (value is not null)
    Process(value);

if (animal is not (Cat or Dog))
    throw new ArgumentException("Only cats and dogs allowed");

8. Real-World: Command Routing

Pattern matching is ideal for routing commands to handlers in a CQRS-style architecture without a framework:

C#
public abstract record Command;
public record CreateUser(string Email, string Name) : Command;
public record DeleteUser(Guid UserId) : Command;
public record UpdateEmail(Guid UserId, string NewEmail) : Command;
public record BanUser(Guid UserId, string Reason, DateTime Until) : Command;

public async Task<CommandResult> DispatchAsync(Command command) => command switch
{
    CreateUser { Email: var email } when !email.Contains('@')
        => CommandResult.Fail("Invalid email"),

    CreateUser cmd
        => await _userService.CreateAsync(cmd),

    DeleteUser { UserId: var id } when !await _userService.ExistsAsync(id)
        => CommandResult.Fail("User not found"),

    DeleteUser cmd
        => await _userService.DeleteAsync(cmd),

    UpdateEmail cmd
        => await _userService.UpdateEmailAsync(cmd),

    BanUser { Until: var until } when until <= DateTime.UtcNow
        => CommandResult.Fail("Ban expiry must be in the future"),

    BanUser cmd
        => await _userService.BanAsync(cmd),

    _ => throw new NotSupportedException($"Unknown command: {command.GetType().Name}"),
};

9. Real-World: HTTP Response Mapping

C#
public record HttpResult(int StatusCode, string? Body, string? Error);

public ApiResponse MapToApiResponse(HttpResult result) => result switch
{
    { StatusCode: 200, Body: var body }          => ApiResponse.Ok(body!),
    { StatusCode: 201, Body: var body }          => ApiResponse.Created(body!),
    { StatusCode: 204 }                          => ApiResponse.NoContent(),
    { StatusCode: 400, Error: var err }          => ApiResponse.BadRequest(err!),
    { StatusCode: 401 }                          => ApiResponse.Unauthorized(),
    { StatusCode: 403 }                          => ApiResponse.Forbidden(),
    { StatusCode: 404 }                          => ApiResponse.NotFound(),
    { StatusCode: >= 500, Error: var err }       => ApiResponse.ServerError(err ?? "Unknown error"),
    { StatusCode: var code }                     => ApiResponse.Unexpected(code),
};

Interview Questions

Q: What is the difference between a switch statement and a switch expression?

A switch statement executes code in arms with break statements and has no return value. A switch expression is an expression — it evaluates to a value, every arm is pattern => expression, and the compiler checks exhaustiveness. Switch expressions are preferred in modern C# because they are more concise and type-safe.

Q: What does the discard pattern _ do in a switch expression?

It matches any value not matched by previous arms. Without it, the compiler may warn that the switch is not exhaustive, and at runtime an unmatched value throws SwitchExpressionException. For sealed hierarchies where the compiler can verify all types are covered, you can omit _.

Q: When would you use a guard (when) instead of a property pattern?

When the condition requires logic that patterns cannot express: method calls, range checks on computed values, or multi-step boolean expressions. Patterns are purely structural; guards run arbitrary code after a pattern matches.

Q: What is a list pattern and when did it arrive in C#?

C# 11. It matches the shape and contents of any type that has a Length or Count property and an integer indexer — arrays, List<T>, Span<T>, string. The slice pattern (..) matches zero or more elements and can optionally bind to a variable.

Enjoyed this article?

Explore the Backend Systems learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.