Skip to main content
C# · lesson 20 of 26
C# intermediate Lesson 20 of 26

Pattern Matching in C#

C# pattern matching by example: type, constant, relational, property, positional and list patterns, switch expressions, and exhaustiveness warnings.

By SkillByExample · Updated

Type Patterns with is

is tests a value’s type and, if it matches, binds it to a new typed variable in one step.

object[] values = { "Hello, C#!", "hi", 42, 3.5, null! };

foreach (object value in values)
{
    if (value is string { Length: > 5 } longText)
        Console.WriteLine($"long string: {longText}");
    else if (value is string s)
        Console.WriteLine($"short string: {s.ToUpper()}");
    else if (value is int n && n > 40)
        Console.WriteLine($"big int: {n + 1}");
    else if (value is not null)
        Console.WriteLine($"something else: {value.GetType().Name}");
    else
        Console.WriteLine("null");
}

object boxed = 42;
Console.WriteLine(boxed as string ?? "(as returned null)");
$ dotnet run
long string: Hello, C#!
short string: HI
big int: 43
something else: Double
null
(as returned null)

s and n are already typed — no cast needed to call ToUpper or do arithmetic. { Length: > 5 } is a property pattern nested inside the type pattern, so "hi" falls through to the next branch. null matches no type pattern at all, not even object, which is why it needed its own branch. as is the older alternative: it returns null on a mismatch instead of binding a variable.

Constant and Relational Patterns in switch

A switch expression maps an input to a value, one arm per pattern. Relational patterns (<, >=) combine with and, or and not.

foreach (int code in new[] { 200, 404, 418, 503 })
    Console.WriteLine($"{code}: {Describe(code)}");

foreach (double bmi in new[] { 17.0, 22.5, 25.0, double.NaN })
    Console.WriteLine($"{bmi}: {Category(bmi)}");

static string Describe(int status) => status switch
{
    200 => "OK",
    404 => "Not Found",
    >= 400 and < 500 => "Client error",
    >= 500 => "Server error",
    _ => $"Unknown status: {status}",
};

static string Category(double bmi) => bmi switch
{
    < 18.5 => "Underweight",
    < 25 => "Normal",
    < 30 => "Overweight",
    >= 30 => "Obese",
    double.NaN => "Invalid",
};
$ dotnet run
200: OK
404: Not Found
418: Client error
503: Server error
17: Underweight
22.5: Normal
25: Overweight
NaN: Invalid

Arms are tried top to bottom, so 404 hits its constant arm before the >= 400 and < 500 range, and 418 falls to the range. Because arms are ordered, Category does not need >= 18.5 and < 25 — reaching the second arm already implies >= 18.5. NaN fails every comparison, so without the explicit double.NaN arm the compiler would warn that the switch is not exhaustive.

Exhaustiveness: The Warning You Should Not Ignore

If the arms don’t cover every possible input, the build warns — and the unmatched value throws at run time.

using System.Runtime.CompilerServices;

foreach (var light in new[] { Light.Red, Light.Green, (Light)7 })
{
    try { Console.WriteLine(Next(light)); }
    catch (SwitchExpressionException ex) { Console.WriteLine($"{ex.GetType().Name}: {ex.UnmatchedValue}"); }
}

static Light Next(Light l) => l switch
{
    Light.Red => Light.Green,
    Light.Green => Light.Yellow,
    Light.Yellow => Light.Red,
};

enum Light { Red, Green, Yellow }
$ dotnet build && dotnet run
Program.cs(9,33): warning CS8524: The switch expression does not handle some values of its input type (it is not exhaustive) involving an unnamed enum value. For example, the pattern '(Light)3' is not covered.
Green
Yellow
SwitchExpressionException: 7

All three named Light values are covered, yet the compiler still warns (CS8524, the enum-specific form of the general CS8509 “not exhaustive” warning): an enum is just an integer, so (Light)3 or (Light)7 is a legal value no arm handles. The warning even suggests an uncovered value. At run time that value throws SwitchExpressionException. Add a _ => throw new ArgumentOutOfRangeException(nameof(l)) arm to make the failure explicit and silence the warning.

Property Patterns

A property pattern matches on an object’s properties, including nested ones via Address.Country.

var customers = new[]
{
    new Customer("Alice", new("London", "UK", "SW1A"), IsPremium: true, CreditLimit: 5000m),
    new Customer("Bob", new("Seattle", "US", "98101"), IsPremium: true, CreditLimit: 500m),
    new Customer("Chen", new("Boston", "US", "02108"), IsPremium: false, CreditLimit: 0m),
};

foreach (var c in customers)
    Console.WriteLine($"{c.Name,-6}{Discount(c),5:P0}  {Zone(c)}");

static decimal Discount(Customer c) => c switch
{
    { IsPremium: true, CreditLimit: >= 5000 } => 0.20m,
    { IsPremium: true } => 0.10m,
    { Address.Country: "UK" } => 0.05m,
    _ => 0m,
};

static string Zone(Customer c) => c switch
{
    { Address: { Country: "US", PostalCode: var zip } } when zip.StartsWith('9') => "US West",
    { Address.Country: "US" } => "US East",
    { Address.Country: "UK" } => "Europe",
    _ => "Other",
};

public record Address(string City, string Country, string PostalCode);
public record Customer(string Name, Address Address, bool IsPremium, decimal CreditLimit);
$ dotnet run
Alice  20 %  Europe
Bob    10 %  US West
Chen    0 %  US East

Alice matches the first Discount arm and never reaches the UK arm below it, so she gets 20%, not 5%. PostalCode: var zip captures a property for use in the when guard, which can run arbitrary code the pattern syntax cannot express. (P0 formats under the invariant culture used for this run, hence 20 % with a space.)

Positional Patterns and Tuples

Positional patterns match through a type’s Deconstruct method. Records get one automatically; tuples work the same way.

foreach (var p in new[] { new Point(0, 0), new Point(3, -1), new Point(0, 7), new Point(-2, 4) })
    Console.WriteLine($"{p}: {Quadrant(p)}");

foreach (var user in new[] { ("admin", 5), ("admin", 1), ("guest", 9) })
    Console.WriteLine($"{user}: {Permission(user)}");

var rect = new Rectangle { Width = 5, Height = 5 };
Console.WriteLine(rect is (var w, var h) && w == h ? "square" : "not square");

static string Quadrant(Point p) => p switch
{
    (0, 0) => "Origin",
    (_, 0) or (0, _) => "On an axis",
    (> 0, > 0) => "Q1",
    (< 0, > 0) => "Q2",
    (< 0, < 0) => "Q3",
    (> 0, < 0) => "Q4",
};

static string Permission((string Role, int Level) u) => u switch
{
    ("admin", >= 5) => "Full access",
    ("admin", _) => "Limited admin",
    _ => "Read only",
};

public record Point(int X, int Y);

public class Rectangle
{
    public int Width { get; init; }
    public int Height { get; init; }
    public void Deconstruct(out int width, out int height) => (width, height) = (Width, Height);
}
$ dotnet run
Point { X = 0, Y = 0 }: Origin
Point { X = 3, Y = -1 }: Q4
Point { X = 0, Y = 7 }: On an axis
Point { X = -2, Y = 4 }: Q2
(admin, 5): Full access
(admin, 1): Limited admin
(guest, 9): Read only
square

Quadrant compiles with no discard arm and no warning: the compiler proved that origin, axes and four sign combinations cover every (int, int). Rectangle is a plain class, but its hand-written Deconstruct is enough to use it in a positional pattern.

List Patterns (C# 11)

List patterns match the shape of an array or list: [] is empty, .. is “any number of elements”, and .. var rest captures them.

foreach (var arr in new int[][] { [], [1], [1, 2], [1, 2, 3, 4, 5], [7, 8, 5] })
    Console.WriteLine($"[{string.Join(",", arr)}] -> {Describe(arr)}");

int[] many = { 1, 2, 3, 4, 5 };
if (many is [var first, .. var rest, var last])
    Console.WriteLine($"first {first}, middle [{string.Join(",", rest)}], last {last}");

foreach (var argv in new[] { new[] { "run", "--port", "8080" }, new[] { "build" }, new[] { "deploy", "prod" } })
    Console.WriteLine(Command(argv));

static string Describe(int[] arr) => arr switch
{
    [] => "empty",
    [var x] => $"single {x}",
    [var x, var y] => $"pair {x},{y}",
    [1, 2, ..] => "starts with 1, 2",
    [.., 5] => "ends with 5",
    _ => $"{arr.Length} elements",
};

static string Command(string[] args) => args switch
{
    ["run", "--port", var port, ..] => $"running on port {port}",
    ["build", ..] => "building",
    [var cmd, ..] => $"unknown command '{cmd}'",
    [] => "no arguments",
};
$ dotnet run
[] -> empty
[1] -> single 1
[1,2] -> pair 1,2
[1,2,3,4,5] -> starts with 1, 2
[7,8,5] -> ends with 5
first 1, middle [2,3,4], last 5
running on port 8080
building
unknown command 'deploy'

[1, 2, 3, 4, 5] matches “starts with 1, 2” before it can reach “ends with 5” — first match wins again. [7, 8, 5] only ends with 5. The capture in the middle of [var first, .. var rest, var last] produces a new array holding the three middle elements.

Combining Patterns

and, or and not build larger patterns; a switch over object can mix types, properties and ranges.

object?[] results = { null, "", "text", -5, 2000, 42, new List<int> { 1, 2, 3 }, 3.14 };

foreach (var r in results)
    Console.WriteLine(Summarize(r));

Console.WriteLine(42 is > 0 and < 100);
Console.WriteLine('x' is >= 'a' and <= 'z' or >= 'A' and <= 'Z');

static string Summarize(object? result) => result switch
{
    null => "null result",
    string { Length: 0 } => "empty string",
    string s => $"string: {s}",
    int n and (< 0 or > 1000) => $"out-of-range int: {n}",
    int n => $"int: {n}",
    IList<int> { Count: var count } => $"list with {count} items",
    _ => $"unknown: {result.GetType().Name}",
};
$ dotnet run
null result
empty string
string: text
out-of-range int: -5
out-of-range int: 2000
int: 42
list with 3 items
unknown: Double
True
True

not, and and or have that precedence, tightest first, and parentheses override it — int n and (< 0 or > 1000) needs them, while the letter check reads correctly without any. The IList<int> arm pulls Count out with var count rather than calling a method, and the discard arm reports the one type nothing else handled.

Practice

  1. FizzBuzz with a tuple switch. Print 1-15 using (i % 3, i % 5) switch.
Solution
for (int i = 1; i <= 15; i++)
{
    Console.Write((i % 3, i % 5) switch
    {
        (0, 0) => "FizzBuzz",
        (0, _) => "Fizz",
        (_, 0) => "Buzz",
        _ => i.ToString(),
    } + " ");
}
Console.WriteLine();
$ dotnet run
1 2 Fizz 4 Buzz Fizz 7 8 Fizz Buzz 11 Fizz 13 14 FizzBuzz 
  1. Shipping cost. Write a switch over record Parcel(double WeightKg, string Country) that charges 5 for UK parcels under 2 kg, 12 for other UK parcels, 20 for anything under 2 kg elsewhere, and 35 otherwise.
Solution
foreach (var p in new[] { new Parcel(1.2, "UK"), new Parcel(4, "UK"), new Parcel(0.5, "FR"), new Parcel(9, "US") })
    Console.WriteLine($"{p} -> {Cost(p)}");

static int Cost(Parcel p) => p switch
{
    { Country: "UK", WeightKg: < 2 } => 5,
    { Country: "UK" } => 12,
    { WeightKg: < 2 } => 20,
    _ => 35,
};

public record Parcel(double WeightKg, string Country);
$ dotnet run
Parcel { WeightKg = 1.2, Country = UK } -> 5
Parcel { WeightKg = 4, Country = UK } -> 12
Parcel { WeightKg = 0.5, Country = FR } -> 20
Parcel { WeightKg = 9, Country = US } -> 35
  1. Fix the traffic light. Make Next exhaustive so the build has no warning and (Light)7 produces a clear exception.
Solution
try { Console.WriteLine(Next((Light)7)); }
catch (ArgumentOutOfRangeException ex) { Console.WriteLine(ex.Message); }

static Light Next(Light l) => l switch
{
    Light.Red => Light.Green,
    Light.Green => Light.Yellow,
    Light.Yellow => Light.Red,
    _ => throw new ArgumentOutOfRangeException(nameof(l), l, "unknown light"),
};

enum Light { Red, Green, Yellow }
$ dotnet build && dotnet run
unknown light (Parameter 'l')
Actual value was 7.

No warning line this time, and the exception says exactly which value was bad.

  1. Palindrome with list patterns. Write a recursive IsPalindrome(char[] s) using [var a, .. var mid, var b].
Solution
foreach (var w in new[] { "racecar", "abba", "abca", "x", "" })
    Console.WriteLine($"{w}: {IsPalindrome(w.ToCharArray())}");

static bool IsPalindrome(char[] s) => s switch
{
    [] or [_] => true,
    [var a, .. var mid, var b] => a == b && IsPalindrome(mid),
};
$ dotnet run
racecar: True
abba: True
abca: False
x: True
: True

Zero or one character is a palindrome; otherwise compare the ends and recurse on the middle.

Frequently Asked Questions

What is the difference between is and as?
'is' returns true/false and can bind a variable. 'as' returns the cast result or null without throwing. Use 'is' with a variable binding (if obj is string s) over 'as' plus null check — it's cleaner and works for non-nullable value types too.
What are positional patterns?
Positional patterns match against a type's Deconstruct method. If a type has Deconstruct(out int x, out int y), you can match it with (> 0, > 0) and the compiler calls Deconstruct for you. Records get Deconstruct generated automatically.
What are list patterns?
List patterns (C# 11) match against sequences. [1, 2, ..] matches a sequence starting with 1, 2. [_, _, ..rest] matches any sequence with at least 2 elements and captures the remainder. They work on arrays, spans, and any type implementing a Length/Count and indexer.
What happens when no arm of a switch expression matches?
The compiler warns at build time that the switch expression does not handle all possible inputs (CS8509, or CS8524 when the only gap is unnamed enum values), and if an unhandled value arrives at run time it throws SwitchExpressionException. Add a discard arm (_ => ...) or cover every case to make the switch exhaustive; do not ignore the warning.
Does the order of switch expression arms matter?
Yes. Arms are tested top to bottom and the first match wins, so a general pattern placed above a specific one hides it. The compiler catches the worst case — an arm that can never be reached because an earlier arm already covers everything it would match — with error CS8510.