← Open in the full interactive course (progress tracking, search & more)

Pattern matching lets you ask "what shape is this, and what's inside it?" in a single expression.

Think about how much C# code boils down to the same question, asked over and over: "What is this thing, exactly, and what does it look like right now?" Is this order shipped or pending? Is this shape a circle or a rectangle? Is this customer's age over 18 and do they have a verified account? Traditionally, answering that meant a pile of is checks, casts, and property comparisons chained together with && and ||.

Pattern matching gives C# a much more direct way to ask that question. Instead of describing how to check something step by step, you describe what shape you're looking for, and the compiler does the checking and, often, the extraction of the interesting parts.

In this lesson, you'll learn type patterns, property patterns, relational patterns, and the and/or/not pattern combinators — the building blocks for a much richer way of testing and destructuring values with is. (The deep dive into pattern matching inside switch expressions is the next lesson.)

What Is It?

The Simple Explanation

Pattern matching is a way to ask "does this value match a certain shape?" — and if it does, pull the interesting parts out at the same time. You've actually already used the simplest form of it, whether you realized it or not: if (obj is string s) is a pattern — it checks the type and gives you a usable variable s in one step.

Modern C# extends that same is keyword to check far more than just "is this a certain type." You can check the values of properties, whether a number falls in a range, or combine several checks with readable words like and, or, and not.

The Technical Definition

Pattern matching in C# is a language feature (introduced in C# 7, substantially expanded through C# 8–11) that lets an expression be tested against a pattern — a syntactic description of a value's shape — using the is operator (or, as you'll see in the next lesson, a switch). A pattern can test the runtime type of a value, the values of its properties, whether a value falls within a numeric range, or a logical combination of other patterns — and can simultaneously bind matched portions to new variables for use afterward.

Pattern kind

What it checks

Why Does It Exist?

The Problem

Consider deciding a shipping cost based on an order's weight and destination. Written the classic way, you get a wall of type-checks, casts, and conditions where the actual business rule is hard to see through the scaffolding:

decimal GetShippingCost(Order order)
{
    if (order != null)
    {
        if (order.Destination == "International")
        {
            if (order.WeightKg > 20)
            {
                return 75m;
            }
        }
        else if (order.Destination == "Domestic" && order.WeightKg <= 5)
        {
            return 5m;
        }
    }
    return 10m;
}

Every condition needs its own if, its own comparison, its own place in the nesting. As the number of rules grows, this becomes genuinely difficult to read and to verify for completeness.

The Need

Developers needed a way to describe "the shape of a match" — a type, a set of property values, a numeric range, a combination of conditions — as directly and declaratively as possible, so the code reads like the actual rule instead of the mechanics of checking it.

The Solution

Pattern matching lets you write the same rule as a direct description of the shape you're testing for:

bool isExpensiveInternational =
    order is { Destination: "International", WeightKg: > 20 };

One expression, reading almost like the sentence "order is International with weight over 20" — because that's exactly what it checks.

Big Picture

FROM SIMPLE TO RICH PATTERNS
obj is Circle — just a type check

obj is Circle c — type check + capture the variable

obj is Circle { Radius: > 10 } — type check + inspect a property

obj is Circle { Radius: > 10 } and not { Filled: true } — combine multiple conditions

How It Works

THE PATTERN TYPES — STEP BY STEP
1. TYPE PATTERN — "IS THIS A CERTAIN TYPE?"
object shape = new Circle(5);

if (shape is Circle circle)
{
    // circle is now a usable, typed Circle variable — no separate cast needed
    Console.WriteLine(circle.Radius);
}
2. PROPERTY PATTERN — "AND DOES IT LOOK LIKE THIS?"
if (order is Order { Destination: "International", WeightKg: 20 })
{
    // matches only if BOTH properties equal these exact values
}
3. RELATIONAL PATTERN — "IS IT GREATER/LESS/etc.?"
if (order is Order { WeightKg: > 20 })
{
    // WeightKg must be strictly greater than 20
}
4. COMBINATORS — and / or / not
if (order is { WeightKg: > 0 and <= 5 })       // "and" — a range in a single pattern
if (order is { Destination: "US" or "Canada" })  // "or" — either value matches
if (order is not { Status: "Cancelled" })        // "not" — negates a pattern

Simple Example

public abstract class Shape { }
public class Circle : Shape { public double Radius { get; init; } }
public class Rectangle : Shape { public double Width { get; init; } public double Height { get; init; } }

double Describe(Shape shape)
{
    if (shape is Circle { Radius: > 10 } bigCircle)
    {
        return bigCircle.Radius * 2; // a "large" circle's diameter
    }

    if (shape is Rectangle { Width: var w, Height: var h } && w == h)
    {
        return w; // it's actually a square — return the side length
    }

    return 0;
}

Code → Meaning → Result: shape is Circle { Radius: > 10 } bigCircle checks three things in one breath — is it a Circle, is its Radius greater than 10, and if both are true, capture the whole matched circle as bigCircle so you can use it right away. No separate cast, no separate comparison.

Real-World Example

A shipping-cost calculator that reasons about weight and destination is a natural fit — the rules genuinely are "shapes" of an order:

public class Order
{
    public required string Destination { get; init; }
    public required double WeightKg { get; init; }
    public bool IsMember { get; init; }
}

decimal CalculateShippingCost(Order order)
{
    if (order is { IsMember: true, WeightKg: <= 10 })
        return 0m; // free shipping for members on lighter packages

    if (order is { Destination: "Domestic", WeightKg: > 0 and <= 5 })
        return 5m;

    if (order is { Destination: "Domestic" })
        return 12m;

    if (order is { Destination: "International", WeightKg: > 20 })
        return 75m;

    if (order is { Destination: "International" })
        return 35m;

    return 15m; // fallback for anything unmatched
}

Each rule reads as a direct description of the order shape it targets — "a member with a package of 10kg or less," "domestic and light," "international and heavy" — instead of a maze of boolean logic. The next lesson shows how the same rules collapse further into a single switch expression.

Analogy

A shape-sorting toy

Think of a shape-sorting toy for toddlers: each hole is cut to accept only a specific shape and size. You don't need to "measure" the block by hand and reason about it — you just try it against the hole, and it either fits or it doesn't.

A pattern is that hole. Circle { Radius: > 10 } is a hole shaped "a circle, and a big one." When you write shape is Circle { Radius: > 10 } c, you're holding the block up to that hole — if it fits, you also get to keep the piece (c) to use afterward.

Under the Hood

WHAT THE COMPILER GENERATES
PATTERNS COMPILE TO ORDINARY TYPE CHECKS AND COMPARISONS

There's no runtime magic here. shape is Circle { Radius: > 10 } c compiles to roughly:

bool matched = false;
Circle? c = shape as Circle;
if (c != null && c.Radius > 10)
{
    matched = true;
}

A type pattern uses as/type-check machinery under the hood (an isinst IL instruction), a property pattern reads the property and compares it, and a relational pattern is just a comparison operator — the compiler is generating exactly the branching code you would have written by hand, just from a much more compact source form.

Common Confusion

1. Pattern and/or vs boolean &&/||

and and or only combine patterns, inside a pattern context (after is, or inside a switch arm). They cannot combine two arbitrary boolean expressions the way && and || can. You can't write x is > 5 and y > 3 mixing an unrelated variable in — every operand around and/or must itself be a valid pattern applied to the same value being tested.

2. A property pattern with no type name still checks the type implicitly

order is { Destination: "Domestic" } (without writing Order before the braces) still works, and it's still safe against null — if order is null, the property pattern simply doesn't match (rather than throwing). You only need to name the type explicitly when you're testing a value whose declared type is a base type or object, and you need to narrow it.

3. Relational patterns only work on values that support comparison

> 20 works on numeric types (and any type implementing the right comparison operators). It doesn't magically apply "greater than" semantics to arbitrary reference types unless those comparisons are actually defined for them.

Common Mistakes

Mistake 1 — Forgetting operator precedence with and/or/not

Assuming not binds looser than it does:

// Intending "not Domestic, and weight over 20" — but precedence groups "not" tightly:
order is not { Destination: "Domestic" } and { WeightKg: > 20 }

Just like boolean operators, not binds tighter than and, which binds tighter than or. Use parentheses around sub-patterns whenever the grouping isn't immediately obvious:

order is (not { Destination: "Domestic" }) and { WeightKg: > 20 }

Mistake 2 — Reaching for a long chain of if (x is ...) when a switch expression fits better

Pattern matching with is is great for one or two conditions, but once you have several mutually exclusive shapes to test, a switch expression (next lesson) expresses that far more clearly and gives the compiler a chance to warn you about gaps — a long if/else if chain of patterns doesn't get that exhaustiveness checking.

Mistake 3 — Using a relational pattern where a simple comparison would already be clearer

if (age is >= 18) for a single, standalone check is technically valid but adds nothing over the plain if (age >= 18) most readers expect. Relational patterns earn their keep when they're part of a richer pattern (a property pattern, a range with and, or a switch arm) — not as a stylistic replacement for every comparison in your codebase.

When Should I Use It?

Mental Model

A pattern = a description of a shape you're testing a value against.
Type pattern = "is it this kind of thing?"
Property pattern = "and does it look like this?"
Relational pattern = "and does it fall in this range?"
and / or / not = the plain-English glue that combines patterns

Remember: under the hood, it's all still type checks and comparisons — patterns just let you describe the shape you want in one readable expression instead of building it by hand with ifs.

Key Takeaway


Check Your Understanding

You've seen type, property, and relational patterns, plus the and/or/not combinators. Let's see if the shapes stuck.

1. What does shape is Circle { Radius: > 10 } bigCircle do, step by step?

Show answer

Correct: B

Why B is correct: This is a type pattern (Circle) combined with a property pattern (Radius: > 10) and a variable designation (bigCircle) — all three checks/bindings happen together as one expression.

Why A is incorrect: The property pattern is not ignored — it's a required part of the match; if Radius isn't greater than 10, the whole pattern fails even if shape is a Circle.

Why C is incorrect: is patterns never throw on a non-match — they simply evaluate to false.

Why D is incorrect: Patterns only read and test values; they never mutate the object being matched.

Reinforcement: A pattern can combine a type check, property checks, and variable capture into a single boolean expression.

2. Which relational pattern correctly expresses "weight is more than 0 and at most 5 kilograms"?

Show answer

Correct: B

Why B is correct: and combines two relational patterns to express a range — the value must satisfy both conditions at once, which is exactly "more than 0 and at most 5."

Why A is incorrect: This mixes the boolean operator || with pattern syntax, which isn't valid — pattern combinators use the words and/or, not &&/||. Also, or here would be logically wrong anyway (almost every number satisfies "greater than 0 or less-equal 5").

Why C is incorrect: There's no such pattern syntax in C#.

Why D is incorrect: Range syntax with .. exists for indices/ranges (like array slicing), not as a relational pattern for arbitrary numeric comparisons.

Reinforcement: Combine relational patterns with the and keyword to express a numeric range in a single pattern.

3. If order is null, what happens when you evaluate order is { Destination: "Domestic" }?

Show answer

Correct: B

Why B is correct: Pattern matching is null-safe by design — a property pattern first implicitly checks that the value isn't null before inspecting its properties, so a null value simply fails to match rather than causing an exception.

Why A is incorrect: This is exactly the kind of unsafe dereference pattern matching is designed to avoid — there's no exception here.

Why C is incorrect: Null never automatically matches a property pattern; the pattern requires an actual object with the specified property value.

Why D is incorrect: This is valid, well-formed C# that compiles and runs safely regardless of whether order is null.

Reinforcement: Property patterns are inherently null-safe — no separate null-check is needed before using one.

4. You want to match "any shape that is NOT a cancelled order AND has a weight over 20." Which correctly expresses this, respecting that not binds tighter than and?

Show answer

Correct: A

Why A is correct: Because not binds more tightly than and, option A correctly parses as (not { Status: "Cancelled" }) and { WeightKg: > 20 } — exactly "not cancelled, and heavy" — which is the intended rule.

Why B is incorrect: Wrapping both sub-patterns inside not(...) changes the meaning to "NOT (cancelled AND heavy)." That's a different rule: it would still match a cancelled-but-light order (since it isn't both cancelled and heavy), even though the intended rule — "not cancelled, and heavy" — should reject any cancelled order outright. The two expressions are not equivalent, which is exactly why grouping matters.

Why C is incorrect: As explained, they parse to logically different expressions — precedence changes the meaning.

Why D is incorrect: Both are syntactically valid C# pattern expressions; the issue is about meaning, not validity.

Reinforcement: Pattern combinators follow a real precedence order (not tightest, then and, then or) — use parentheses whenever the intended grouping isn't obvious at a glance.

5. What do type patterns, property patterns, and relational patterns all compile down to, under the hood?

Show answer

Correct: B

Why B is correct: Pattern matching doesn't introduce new runtime behavior — the compiler translates each pattern into the same type checks, property reads, and comparisons you'd write manually. Patterns are a more compact and readable way to express that logic, not a different execution mechanism.

Why A is incorrect: There's no exotic new runtime machinery involved — it compiles to standard IL you'd recognize from hand-written conditional code.

Why C is incorrect: No reflection is used for compile-time-known patterns like these — type checks use standard type-testing IL instructions, not reflection.

Why D is incorrect: There's no dictionary or lookup table involved; it's straightforward sequential branching logic.

Reinforcement: Pattern matching is a syntax-level convenience over the exact same checks you'd otherwise hand-write — understanding that demystifies what's "really" happening.

You can now describe the "shape" of a value directly in code instead of reconstructing it from scattered if checks. Next up: putting these same patterns to work inside switch expressions.


dotnetmadeeasy.com — Learn C# and .NET, the right way.