Pattern Matching: Type, Constant and Relational Patterns, and switch Expressions
Objective
Pattern matching lets a single expression ask "what is this value?" and branch
on the answer: its runtime type, whether it equals a constant, which range it
falls in, or a logical combination of those. The same patterns work in is,
in the switch statement, and in the switch expression. What makes them
more than syntax is the compiler's analysis: it knows when a switch
expression leaves values unhandled, when an arm can never be reached, and when
an is test is always or never true, and it compiles the arms into a decision
tree instead of a list of ifs. This concept covers the type, constant,
relational, logical, var, and discard patterns, when guards, exhaustiveness
and subsumption with the real compiler messages, and what the generated code
looks like. Property, positional, and list patterns are the next concept.
Use Cases
- Mapping an enum or a status code to a value in one expression, with the compiler pointing out the case you forgot.
- Classifying numbers into ranges (tax brackets, HTTP status families, grades)
without chains of
if/else if. - Handling an
objector a base type that can hold several concrete types, such as a message from a queue or a JSON node. - Replacing
x != null && x is Fooand casts with a singlex is Foo foo.
Deep Dive
The catalog
csharpstatic string Describe(object? o) => o switch
{
null => "null constant", // constant pattern
0 => "zero", // constant pattern (int)
int n and > 0 => $"positive int {n}", // declaration + relational, combined with and
int => "other int", // type pattern (C# 9)
string { Length: 0 } => "empty string", // type + property pattern
string s => $"string of {s.Length}", // declaration pattern
char c and (>= 'a' and <= 'z') => $"lowercase {c}",
long or short => "long or short", // or of two type patterns
_ => $"something else ({o.GetType().Name})", // discard
};
Describe(42); // positive int 42
Describe(-3); // other int
Describe(""); // empty string
Describe('x'); // lowercase x
Describe(7L); // long or short
Describe(new[] {1}); // something else (Int32[])A few rules are worth knowing precisely:
- Type and declaration patterns never match
null.maybe is stringisfalsefor a nullstring?. They match the runtime type through identity, inheritance, interface implementation, boxing or unboxing, and aNullable<T>with a value (int? ni = 5; ni is int xgivesx = 5). They ignore user-defined conversions. varpatterns match everything, includingnull.maybe is var vistrueeven whenmaybeisnull. Use it to capture an intermediate value, not to test anything.- Constant patterns accept numeric,
char,string,bool, enum, andconstvalues, andnull.x is nullnever calls a user-defined==, which is why it is preferred overx == null. - Relational patterns (
<,<=,>,>=) take a constant of numeric,char, or enum type. They do not matchnull, and they do not matchNaN, which must be handled with the constant patterndouble.NaN.
Logical patterns and precedence
not binds tighter than and, which binds tighter than or. That makes one
mistake very easy to write:
csharpstatic bool WrongNotLower(char c) => c is not >= 'a' and <= 'z'; // (not >= 'a') and <= 'z'
static bool RightNotLower(char c) => c is not (>= 'a' and <= 'z');
WrongNotLower('~'); // False: '~' is above 'z', so "<= 'z'" fails
RightNotLower('~'); // TrueThe compiler does not catch every variant. n is not 1 or 2 means
(not 1) or 2, so the 2 is redundant and the expression is just
n is not 1, yet the .NET 10.0.100 SDK builds it without a warning. When in
doubt, add parentheses.
when guards
A guard adds an arbitrary boolean condition after a pattern. It runs only when the pattern already matched, and it is the only place for checks that patterns cannot express, such as comparing two captured values or calling a method:
csharpdouble d when double.IsFinite(d) => $"finite double {d}",The price is that the compiler cannot reason about guards. They do not count toward exhaustiveness, as the next section shows.
Exhaustiveness
A switch expression must produce a value for every input. When the compiler
can find an input that no arm handles, it warns and shows an example. These are
the messages from the .NET 10.0.100 SDK:
csharpn switch { < 0 => "negative", 0 => "zero" }
// warning CS8509: The switch expression does not handle all possible values of its
// input type (it is not exhaustive). For example, the pattern '1' is not covered.
c switch { Color.Red => "red", Color.Green => "green", Color.Blue => "blue" }
// warning CS8524: ... involving an unnamed enum value. For example, the pattern
// '(Color)3' is not covered.
s switch { string text => text } // s is string?
// warning CS8655: The switch expression does not handle some null inputs
// (it is not exhaustive). For example, the pattern 'null' is not covered.
n switch { var x when x >= 0 => "non-negative", var x when x < 0 => "negative" }
// warning CS8846: ... For example, the pattern '_' is not covered. However, a
// pattern with a 'when' clause might successfully match this value.The enum case surprises people: an enum is just an integer, so (Color)42 is a
legal value. If the warning is ignored and such a value arrives, the runtime
throws:
csharpName((Color)42);
// SwitchExpressionException: Non-exhaustive switch expression failed to match its input.
// Unmatched value was 42.Class hierarchies get no help in C# 14. Even with an abstract base and two
sealed subclasses, both handled, the compiler still reports CS8509 with the
pattern '_', because another assembly could add a subclass. The documentation
for C# 15, which is still in preview, describes closed classes that change
this; in C# 14 you need a discard arm. A bool switch with true and false
arms, on the other hand, is exhaustive and compiles silently.
Subsumption: arms that can never run
Arms are tried in text order, so an arm whose values were all handled earlier is dead code. The compiler makes that an error, not a warning:
csharpn switch { > 0 => "positive", > 10 => "big", _ => "other" }
// error CS8510: The pattern is unreachable. It has already been handled by a
// previous arm of the switch expression or it is impossible to match.
o switch { string s => s, "hello" => "greeting", _ => "other" } // same CS8510
switch (n) { case > 0: break; case 5: break; }
// error CS8120: The switch case is unreachable. It has already been handled by a
// previous case or it is impossible to match.The same analysis runs on is: n is > 5 and < 3 is error CS8518 ("An
expression of type 'int' can never match the provided pattern"), and n is int
on an int is warning CS0183 ("The given expression is always of the provided
('int') type").
What the compiler generates
Arms are not compiled as a sequence of if statements. Roslyn builds a decision
graph that shares tests between arms and orders them for speed. Disassembling a
Release build shows:
- Dense integer constants (
0 => ..., 1 => ..., 4 => ...) become a single ILswitchinstruction, a jump table. - Relational ranges (
< 0,< 10,< 100,_) become a binary search: the first comparison in the IL is against10, the middle boundary, not against0, the first arm. - String constants are dispatched by
Lengthwith an ILswitch, then by a character at a chosen position, and only then confirmed withstring ==. For seven HTTP verbs, no hash is computed at all. - Type patterns become
isinst, and two arms on the same type (string s when s.Length > 3andstring) share oneisinst string.
The practical consequence: arm order matters for which result you get, but you do not need to reorder arms for speed. Write them in the order that reads best and lets the subsumption check protect you.
Trade-offs
- A discard arm silences the exhaustiveness check forever.
_ => throwmakes today's code safe, but when someone addsColor.Yellowthe compiler stays quiet. For enums you own, prefer listing every member and accepting CS8524, or throw with the value so the failure is obvious.csharp_ => throw new ArgumentOutOfRangeException(nameof(c), c, "Unknown color"), - Guards defeat the analysis. Every
whenis opaque to exhaustiveness and subsumption. Express the condition as a pattern (> 0instead ofwhen x > 0) whenever it is possible. switchon types is not polymorphism. A type switch puts the behavior next to the caller, which is good for closed sets you control (messages, results) and bad when new types appear often: every switch must change. A virtual method keeps the behavior with the type.- Precedence of
not,and,oris easy to misread, and not every mistake produces a diagnostic. Parenthesize anything withnotand a combinator. NaNandnullfall through relational arms.< 0and>= 0together do not cover adouble. Adddouble.NaNandnullarms explicitly when the input type allows them.
Documentation Links
- Patterns, C# reference, Microsoft Learn
- switch expression, C# reference, Microsoft Learn
- Pattern matching overview, Microsoft Learn
- Selection statements: if and switch, C# reference, Microsoft Learn
- Errors and warnings for pattern matching, Microsoft Learn
- SwitchExpressionException class, Microsoft Learn