DEV_NET_CORE
GET_STARTED
.NETModern C# patterns

Pattern Matching in C#

Overview

Pattern matching in C# is a language feature that lets you test whether a value has a specific shape, type, value, or set of properties, and then safely use the matched data. Instead of writing long if statements, repeated casts, nested null checks, or fragile conditional logic, pattern matching gives you a concise and expressive way to describe what a value must look like.

Pattern matching is commonly used with:

  • is expressions
  • switch statements
  • switch expressions
  • type checks and safe casts
  • null checks
  • enum and constant matching
  • validation logic
  • DTO and request inspection
  • domain model rules
  • recursive object checks
  • tuple and record deconstruction
  • list or array shape checks

It matters because modern C# code often deals with data from many sources: API requests, database records, messages, commands, background jobs, external integrations, and domain objects. Pattern matching helps make that decision logic easier to read, safer to maintain, and less error-prone.

For interviews, pattern matching is important because it tests several practical skills at once: understanding of C# syntax, type safety, nullable handling, control flow, switch expressions, records, deconstruction, clean conditional logic, and when pattern matching is better or worse than polymorphism.

Core Concepts

What Pattern Matching Means

Pattern matching means comparing an input value against a pattern. A pattern describes the expected form of the value.

A pattern can check things such as:

  • whether a value is null or not null
  • whether a value is a specific type
  • whether a value equals a constant
  • whether a number is inside a range
  • whether an object has specific property values
  • whether a tuple or record has specific positional values
  • whether an array or list has a specific structure
  • whether several conditions are true using and, or, and not

Simple example:

Code
object value = "Hello";

if (value is string text)
{
    Console.WriteLine(text.ToUpperInvariant());
}

The expression value is string text checks whether value is a string. If the match succeeds, C# assigns the matched value to the variable text.

This is cleaner and safer than the older style:

Code
object value = "Hello";

if (value is string)
{
    string text = (string)value;
    Console.WriteLine(text.ToUpperInvariant());
}

Pattern Matching Constructs

C# pattern matching is mainly used in three places.

is Expression

The is expression checks whether a value matches a pattern.

Code
public static string Describe(object? input)
{
    if (input is null)
    {
        return "No value";
    }

    if (input is int number)
    {
        return $"Integer: {number}";
    }

    if (input is string text)
    {
        return $"Text: {text}";
    }

    return "Unknown value";
}

The is expression is commonly used for safe type checks, null checks, and simple conditional branching.

switch Statement

The switch statement can use patterns in case labels.

Code
public static void PrintResult(object? result)
{
    switch (result)
    {
        case null:
            Console.WriteLine("No result");
            break;

        case int number when number > 0:
            Console.WriteLine("Positive number");
            break;

        case int number:
            Console.WriteLine($"Number: {number}");
            break;

        case string text:
            Console.WriteLine($"Text: {text}");
            break;

        default:
            Console.WriteLine("Unsupported result");
            break;
    }
}

A switch statement is useful when each branch performs multiple statements or side effects.

switch Expression

A switch expression returns a value based on the first matching pattern.

Code
public static string GetPriorityLabel(int priority) => priority switch
{
    1 => "Low",
    2 => "Medium",
    3 => "High",
    >= 4 => "Critical",
    _ => "Unknown"
};

A switch expression is usually preferred when the goal is to map one value to another value.

Declaration Patterns and Type Patterns

A declaration pattern checks the runtime type of a value and declares a new variable when the match succeeds.

Code
public static decimal CalculateDiscount(object customer)
{
    if (customer is PremiumCustomer premiumCustomer)
    {
        return premiumCustomer.TotalSpent > 10_000 ? 0.15m : 0.10m;
    }

    return 0.00m;
}

A type pattern checks the runtime type without declaring a variable.

Code
public static bool IsSupportedCustomer(object customer)
{
    return customer is PremiumCustomer or StandardCustomer;
}

Use declaration patterns when you need to use the matched value. Use type patterns when you only need to check the type.

Constant Patterns and Null Checks

A constant pattern checks whether a value equals a constant.

Code
public static string GetStatusMessage(int statusCode) => statusCode switch
{
    200 => "OK",
    400 => "Bad Request",
    401 => "Unauthorized",
    404 => "Not Found",
    500 => "Server Error",
    _ => "Unknown Status"
};

A common constant pattern is null.

Code
if (customer is null)
{
    throw new ArgumentNullException(nameof(customer));
}

For null checks, is null and is not null are often preferred because they express intent clearly and are not affected by overloaded equality operators.

Code
if (customer is not null)
{
    Console.WriteLine(customer.Name);
}

Relational Patterns

Relational patterns compare a value with a constant using relational operators such as <, <=, >, and >=.

Code
public static string GetTemperatureDescription(decimal temperature) => temperature switch
{
    < 0 => "Freezing",
    >= 0 and < 20 => "Cold",
    >= 20 and < 30 => "Warm",
    >= 30 => "Hot"
};

Relational patterns are useful for range-based decisions, such as:

  • age groups
  • score ranges
  • tax brackets
  • retry counts
  • priority levels
  • validation thresholds

Logical Patterns: and, or, and not

Logical patterns combine multiple patterns.

Code
public static bool IsValidPercentage(int value)
{
    return value is >= 0 and <= 100;
}

The or pattern matches when any subpattern matches.

Code
public static bool IsWeekend(DayOfWeek day)
{
    return day is DayOfWeek.Saturday or DayOfWeek.Sunday;
}

The not pattern negates another pattern.

Code
public static bool HasValue(string? text)
{
    return text is not null;
}

Logical patterns make conditions more readable when they describe the shape of the data.

Compare this:

Code
if (age >= 18 && age <= 65)
{
    Console.WriteLine("Working age");
}

With this:

Code
if (age is >= 18 and <= 65)
{
    Console.WriteLine("Working age");
}

Both are valid. Pattern matching can be clearer when the expression is part of a larger pattern-based rule.

Property Patterns

Property patterns check properties or fields of an object.

Code
public sealed class Order
{
    public int Id { get; init; }
    public decimal Total { get; init; }
    public bool IsPaid { get; init; }
    public string Country { get; init; } = string.Empty;
}

public static bool CanShip(Order order)
{
    return order is { IsPaid: true, Total: > 0, Country: not "" };
}

The pattern { IsPaid: true, Total: > 0, Country: not "" } means:

  • the object is not null
  • IsPaid must be true
  • Total must be greater than 0
  • Country must not be an empty string

Property patterns are especially useful for DTO validation, domain rules, and branching based on object state.

Example with nested properties:

Code
public sealed class Customer
{
    public string Name { get; init; } = string.Empty;
    public Address? Address { get; init; }
}

public sealed class Address
{
    public string Country { get; init; } = string.Empty;
    public string City { get; init; } = string.Empty;
}

public static bool IsCustomerInVietnam(Customer customer)
{
    return customer is { Address: { Country: "VN" } };
}

This avoids a manual null check like:

Code
customer.Address != null && customer.Address.Country == "VN"

Extended Property Patterns

Extended property patterns allow a more compact syntax for nested properties.

Code
public static bool IsCustomerInVietnam(Customer customer)
{
    return customer is { Address.Country: "VN" };
}

This is often easier to read when checking one or two nested values.

Positional Patterns

Positional patterns match values returned by Deconstruct.

Records automatically support deconstruction for primary constructor parameters.

Code
public readonly record struct Money(decimal Amount, string Currency);

public static string Describe(Money money) => money switch
{
    (0, _) => "No money",
    (> 0, "USD") => "Positive amount in USD",
    (> 0, "VND") => "Positive amount in VND",
    (< 0, _) => "Negative amount",
    _ => "Other money value"
};

The pattern (> 0, "USD") checks the first positional value and the second positional value.

Positional patterns are useful when working with:

  • records
  • tuples
  • small value objects
  • domain values with clear positional meaning
  • types that implement Deconstruct

Example with a custom class:

Code
public sealed class Point
{
    public int X { get; }
    public int Y { get; }

    public Point(int x, int y)
    {
        X = x;
        Y = y;
    }

    public void Deconstruct(out int x, out int y)
    {
        x = X;
        y = Y;
    }
}

public static string Describe(Point point) => point switch
{
    (0, 0) => "Origin",
    (0, _) => "On Y axis",
    (_, 0) => "On X axis",
    (> 0, > 0) => "First quadrant",
    _ => "Other point"
};

Tuple Patterns

Tuple patterns are useful when a decision depends on multiple values.

Code
public static decimal GetShippingCost(bool isPremiumCustomer, decimal orderTotal, string country)
{
    return (isPremiumCustomer, orderTotal, country) switch
    {
        (true, >= 100, _) => 0m,
        (_, >= 200, "VN") => 0m,
        (_, < 50, "US") => 15m,
        (_, _, "VN") => 5m,
        _ => 20m
    };
}

This can be cleaner than nested if statements when multiple inputs determine a result.

However, tuple patterns can become hard to read if too many values are involved. For complex business rules, consider extracting named methods or using a dedicated rule object.

List Patterns and Slice Patterns

List patterns match the structure of arrays, spans, or list-like values.

Code
public static string ParseCommand(string[] args) => args switch
{
    ["run"] => "Run default command",
    ["run", var jobName] => $"Run job: {jobName}",
    ["copy", var source, var destination] => $"Copy from {source} to {destination}",
    ["delete", .. var targets] => $"Delete {targets.Length} target(s)",
    [] => "No command",
    _ => "Unknown command"
};

In this example:

  • ["run"] matches exactly one item
  • ["run", var jobName] matches exactly two items
  • ["copy", var source, var destination] matches exactly three items
  • ["delete", .. var targets] matches a command followed by zero or more remaining items
  • [] matches an empty list

List patterns are useful for command-line arguments, token parsing, small protocol messages, and simple sequence shape checks.

They are not a replacement for full parsing logic when the input grammar is complex.

The var Pattern

The var pattern matches any value and assigns it to a variable.

Code
public static string DescribeLength(string? text) => text switch
{
    null => "No text",
    var value when value.Length == 0 => "Empty text",
    var value when value.Length < 10 => "Short text",
    var value => $"Long text with {value.Length} characters"
};

The var pattern is useful when you want to capture a value inside a switch arm, especially with a when guard.

The Discard Pattern _

The discard pattern _ matches anything and ignores the value.

Code
public static string GetRoleLabel(string role) => role switch
{
    "Admin" => "Administrator",
    "User" => "Standard User",
    _ => "Unknown Role"
};

The discard pattern is often used as the final fallback case in a switch expression.

Use it carefully. A discard arm can hide missing cases if you use it too early or too broadly.

when Guards

A when guard adds an extra condition to a pattern arm.

Code
public static string DescribeCustomer(Customer customer) => customer switch
{
    { Name: "" } => "Missing name",
    { Address.Country: "VN" } when customer.Name.StartsWith("A") => "Vietnam customer with name starting with A",
    { Address.Country: "VN" } => "Vietnam customer",
    _ => "Other customer"
};

A pattern first checks the shape of the value. The when guard then checks an additional Boolean condition.

Use when guards when the rule cannot be expressed cleanly as a pattern alone.

Ordering and First Match Wins

Pattern matching checks switch arms in order. The first matching arm wins.

Code
public static string DescribeNumber(int value) => value switch
{
    > 0 => "Positive",
    > 10 => "Greater than ten",
    _ => "Other"
};

The > 10 arm is unreachable because every value greater than 10 already matches > 0.

Correct version:

Code
public static string DescribeNumber(int value) => value switch
{
    > 10 => "Greater than ten",
    > 0 => "Positive",
    _ => "Other"
};

Put more specific patterns before more general patterns.

Exhaustiveness and Fallback Cases

A switch expression should handle all expected input cases.

Code
public enum PaymentStatus
{
    Pending,
    Paid,
    Failed,
    Refunded
}

public static string GetPaymentMessage(PaymentStatus status) => status switch
{
    PaymentStatus.Pending => "Payment is pending",
    PaymentStatus.Paid => "Payment completed",
    PaymentStatus.Failed => "Payment failed",
    PaymentStatus.Refunded => "Payment refunded",
    _ => "Unknown payment status"
};

The final _ arm handles unexpected values. This is especially useful for enums because an enum variable can contain a numeric value that is not defined by the enum members.

If a switch expression does not match any arm at runtime, an exception is thrown. In practical code, use a fallback arm unless you intentionally want unhandled inputs to fail.

Pattern Variables and Scope

A variable declared in a pattern is only definitely assigned when the pattern matches.

Code
object value = "abc";

if (value is string text)
{
    Console.WriteLine(text.Length);
}

// text is not available here

Pattern variables help reduce unsafe casts and make code more readable.

Another common example:

Code
if (request is { CustomerId: > 0 } validRequest)
{
    Console.WriteLine(validRequest.CustomerId);
}

Here, validRequest is only available inside the if block where the pattern has matched.

Pattern Matching with Nullable Reference Types

Pattern matching works well with nullable reference types.

Code
public static int GetNameLength(Customer? customer)
{
    return customer is { Name: not null } ? customer.Name.Length : 0;
}

The pattern checks that:

  • customer is not null
  • customer.Name is not null

This can make null handling more compact.

However, do not make patterns so dense that they become difficult to understand. Sometimes an explicit guard clause is clearer.

Code
public static int GetNameLength(Customer? customer)
{
    if (customer is null || customer.Name is null)
    {
        return 0;
    }

    return customer.Name.Length;
}

Pattern Matching vs if Statements

Pattern matching does not replace all if statements.

Use pattern matching when the condition describes the shape, type, or value pattern of data.

Code
return order switch
{
    { IsPaid: false } => "Order is not paid",
    { Total: <= 0 } => "Invalid total",
    { Country: "VN" } => "Domestic order",
    _ => "International order"
};

Use normal if statements when the logic is procedural, step-by-step, or depends on multiple side effects.

Code
if (!cache.TryGetValue(key, out var value))
{
    value = LoadFromDatabase(key);
    cache[key] = value;
}

This is not a good pattern matching scenario because the logic is about a workflow, not just matching a value.

Pattern Matching vs Polymorphism

Pattern matching is useful when branching on external data, DTOs, primitive values, tuples, or object shapes.

Polymorphism is often better when behavior naturally belongs inside a type hierarchy.

Pattern matching example:

Code
public static decimal CalculateArea(object shape) => shape switch
{
    Circle circle => Math.PI * circle.Radius * circle.Radius,
    Rectangle rectangle => rectangle.Width * rectangle.Height,
    _ => throw new NotSupportedException("Unsupported shape")
};

This can be acceptable when the shape types are external or cannot be changed.

Polymorphic version:

Code
public abstract class Shape
{
    public abstract decimal CalculateArea();
}

public sealed class Rectangle : Shape
{
    public decimal Width { get; init; }
    public decimal Height { get; init; }

    public override decimal CalculateArea() => Width * Height;
}

Polymorphism is often better when:

  • each type owns its behavior
  • new behavior is rare but new types are common
  • you want to avoid central switch statements
  • the domain model should enforce behavior

Pattern matching is often better when:

  • the data shape is simple
  • the input comes from outside the domain
  • the logic is a small mapping or classification
  • creating a class hierarchy would be overengineering

Pattern Matching with Records

Records work naturally with pattern matching because they are often used as immutable data carriers.

Code
public abstract record Command;
public sealed record CreateOrder(int CustomerId, decimal Total) : Command;
public sealed record CancelOrder(int OrderId) : Command;

public static string Handle(Command command) => command switch
{
    CreateOrder { CustomerId: <= 0 } => "Invalid customer",
    CreateOrder { Total: <= 0 } => "Invalid order total",
    CreateOrder create => $"Create order for customer {create.CustomerId}",
    CancelOrder { OrderId: <= 0 } => "Invalid order id",
    CancelOrder cancel => $"Cancel order {cancel.OrderId}",
    _ => "Unknown command"
};

This is common in command handling, message processing, and domain workflows.

Pattern Matching in ASP.NET Core and APIs

Pattern matching is useful in Web API code when mapping domain results to HTTP responses.

Code
public abstract record Result;
public sealed record Success(object Value) : Result;
public sealed record NotFound(string Message) : Result;
public sealed record ValidationFailed(IDictionary<string, string[]> Errors) : Result;
public sealed record UnauthorizedResult : Result;

public static IResult ToHttpResult(Result result) => result switch
{
    Success success => Results.Ok(success.Value),
    NotFound notFound => Results.NotFound(new { notFound.Message }),
    ValidationFailed validation => Results.ValidationProblem(validation.Errors),
    UnauthorizedResult => Results.Unauthorized(),
    _ => Results.Problem("Unexpected result")
};

This keeps response mapping readable and centralized.

Common Mistakes

A common mistake is making switch expressions too large.

Code
// Hard to maintain if it grows too much
var result = input switch
{
    // many unrelated business rules here
};

If a switch expression becomes too long, split the logic into smaller methods, use polymorphism, or introduce a strategy/rule object.

Another mistake is using _ too early.

Code
public static string Describe(int value) => value switch
{
    _ => "Any value",
    > 10 => "Greater than ten"
};

The second arm can never be reached because _ matches everything.

Another mistake is assuming pattern matching always makes code better. Pattern matching improves readability only when the pattern itself is easy to understand.

Best Practices

Use pattern matching to make value-based and shape-based logic easier to read.

Prefer is null and is not null for clear null checks.

Use switch expressions for simple mappings that return a value.

Use switch statements when each branch needs multiple statements.

Put specific patterns before general patterns.

Include a fallback arm for unexpected input unless failing fast is intentional.

Avoid hiding complex business workflows inside a single large switch expression.

Use property patterns for DTO validation and state checks.

Use positional patterns when the positional meaning is obvious.

Use named property patterns instead of positional patterns when readability is more important than brevity.

Use list patterns for small sequence shape checks, not full parser logic.

Consider polymorphism or strategy patterns when behavior belongs to types rather than to a central decision block.

Interview Practice

PreviousObserver-Style Communication in C#Next UpRecords in C#