Last updated: May 2026. Covers C# 7 through C# 14.
C# Pattern Matching lets you test whether an expression has a specific shape (a type, a value, a structure) and pull data out of it, all in one syntax. After eight years of pattern matching landing in C# 7, the syntax has grown into something genuinely useful. It has also grown into something easy to misuse, which is why this guide is opinionated about when to reach for it and when to leave it alone.
This article walks through every C# pattern matching syntax with code you can actually copy: the is operator, switch expressions, type patterns, property patterns, list patterns, and much more. Each section calls out the C# version that introduced the syntax so you know what is available in your target framework.
C# Pattern Matching Cheat Sheet
If you only want the summary, here it is:
| Pattern | Since | Example | Use when |
|---|---|---|---|
| Null | C# 7 | x is null |
You need a real reference null check, bypassing operator overloads. |
| Constant | C# 7 | code is 404 |
The value to compare is a compile-time constant. |
| Type / Declaration | C# 7 | shape is Circle c |
Types are unrelated by inheritance. Otherwise, use polymorphism. |
| Discard / var | C# 7 | _ => ... |
Default arm of a switch, or as a placeholder inside other patterns. |
| Switch expression | C# 8 | x switch { ... } |
You want a value out of the switch, with exhaustiveness checking. |
| Property | C# 8 | { Age: >= 18 } |
Decision depends on object properties. |
| Positional | C# 8 | (var x, var y) |
The target type has a Deconstruct method or is a positional record. |
| Tuple | C# 8 | (a, b) switch { ... } |
Decision depends on a combination of inputs. |
Logical (and, or, not) |
C# 9 | is not null |
Composing patterns without repeating the variable. |
| Relational | C# 9 | is >= 100 |
Numeric range checks. |
| Extended property | C# 10 | { Address.City: "Paris" } |
Matching on a nested property without nested braces. |
| List / slice | C# 11 | arr is [1, .., 9] |
Small-arity sequence inspection. Beware allocations. |
The rest of this article expands every row.
What is C# Pattern Matching?
C# pattern matching is a language feature that tests whether an expression has a specific shape — a type, a constant, or a structure — and, on a match, extracts its data into typed variables. It works with the is operator, switch statements, and switch expressions, and was introduced in C# 7.
A pattern is a description of what an expression should look like. Pattern matching is the act of testing an expression against that description. The result is a boolean, and on a successful match the language can also extract data into typed variables.
Compared to long chains of if clauses, pattern matching gives you three things that matter:
- The expression under test is named once, not five times.
- Type checks expose the matched value as a typed variable. No casts.
- Switch expressions catch missing cases at compile time.
Here is the canonical before/after. Both snippets do the same job. The second one combines a type pattern with a property pattern, and the difference in readability is hard to argue with:
|
1 2 3 4 5 6 7 8 9 |
// Traditional if statement with multiple conditions and a type cast if (person != null && person.Age >= 18 && person is Employee && ((Employee)person).YearsAtCompany > 5) { ... // Equivalent C# pattern matching version. // The identifier 'person' is mentioned only once. if (person is Employee { Age: >= 18, YearsAtCompany: > 5 }) { ... |
Patterns live in three places: the is operator, the classic switch statement, and the switch expression introduced in C# 8. Learn a pattern once and it works in all three.
The is Operator: Where Pattern Matching Starts
The is operator is the doorway. Any pattern can be tested with expression is pattern, and the result is a bool. The same patterns also work in switch statements and switch expressions.
Pattern matching is not magic, it is only syntactic sugar. At first glance you might assume it works like an IQueryable LINQ expression, where the compiler builds some runtime object the framework then interprets. It does not. The C# compiler lowers patterns to plain IL, the same IL you would write yourself by hand. Compare these two methods:
|
1 2 3 4 5 6 |
public static bool IsLetterOrSeparator1(this char c) => c is (>= 'a' and <= 'z') or (>= 'A' and <= 'Z') or '.' or ','; public static bool IsLetterOrSeparator2(this char c) => (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '.' || c == ','; |
Decompiled, they look essentially identical:
That property has practical consequences. Pattern matching is not slower than equivalent boolean expressions, and as we will see in the performance section, the compiler will occasionally rewrite a pattern more aggressively than it ever rewrites a plain || chain.
Checking for Null: is null and is not null
The simplest pattern matches against null:
|
1 2 3 4 |
static void NullPattern(object o, int? ni) { if (o is null) Console.WriteLine("o is null"); if (!(ni is null)) Console.WriteLine(ni.Value); } |
Since C# 9 the negated form is not null is available, and the C# style guide now recommends it over != null:
|
1 |
if (obj is not null) { ... } // C# 9 |
Why prefer the pattern form over == null? Because == can be overloaded, and there are classes in the wild whose operator == returns true for an instance compared against null (think Unity’s UnityEngine.Object). The pattern form is always a true reference comparison. I have been bitten by this exactly once, and once was enough.
Matching Literal Values: the Constant Pattern
The constant pattern (C# 7) compares against a compile-time constant:
|
1 2 3 4 |
static void ConstantPattern(double d, string str) { if (d is Math.PI) Console.WriteLine("d is PI"); if (str is "314159") Console.WriteLine("str looks like PI"); } |
What counts as a constant:
- integer or floating-point numerical literals
charandstringliterals- the boolean values
trueandfalse - enumeration values
- the name of a declared
constfield, ornull
And here is the catch that has hurt this feature’s adoption more than anything else: the value baked into a pattern must be a compile-time constant. If the value lives in an appsettings.json, in a database, or in a static readonly field initialised at startup, you cannot use it in a pattern. The compiler will reject the code:
This restriction shows up in real codebases often enough that I keep mentioning it. The C# team has not relaxed it yet, and the reasons (exhaustiveness, side effects on the matched-against expression) are not trivial.
The Type Pattern: Test and Capture in One Step
If you only learn one pattern, learn this one. The type pattern (C# 7) checks whether an expression has a given type and, on success, binds the value to a new variable of that type. It replaces the old is-plus-cast dance:
|
1 2 3 4 5 6 7 |
static void TypePattern(Shape shape) { if (shape is Circle circle1) Console.WriteLine($"shape is a circle of radius {circle1.Radius}"); if (shape is Rectangle rect && rect.Width == rect.Height) Console.WriteLine($"shape is a square of length {rect.Width}"); } |
The type pattern earns its keep in switch statements over types that have no common base class. The BCL is full of these situations. Here is a small one: counting the elements of an arbitrary sequence in O(1) when possible:
|
1 2 3 4 5 6 7 8 9 |
public static int Count<T>(this IEnumerable<T> seq) { switch (seq) { case Array a: return a.Length; case ICollection<T> c: return c.Count; case IReadOnlyCollection<T> c: return c.Count; case IEnumerable<T> _: return seq.Count(); // not null default: return 0; // null } } |
Order matters. The compiler will reject case clauses it can prove unreachable. Put ICollection<T> before List<T> and the compiler tells you why:
Declaration Pattern vs Type Pattern: a Distinction Worth Knowing
The C# language specification actually splits this into two named patterns, and those names turn up in compiler messages and on Stack Overflow, so it pays to recognise them:
- The declaration pattern
shape is Circle cchecks the run-time type and binds a new variablecof that type. - The type pattern
shape is Circleonly checks the type and binds nothing. Before C# 9 you had to writeCircle _for this case; since C# 9 the bare type name is enough.
Both reject null: neither form matches a null value, regardless of the static type of the operand. That property makes the declaration pattern the cleanest way to test a nullable value type and unwrap it to its underlying type in a single step:
|
1 2 3 |
int? maybe = 12; if (maybe is int number) // matches only when maybe.HasValue is true Console.WriteLine($"maybe holds the value {number}"); |
The when Clause
Inside a switch, the when keyword adds an arbitrary boolean condition on top of a pattern. This is where you escape the compile-time-constant restriction:
|
1 2 3 4 5 6 7 8 |
public static int CountUpTo10<T>(this IEnumerable<T> seq) { switch (seq) { case Array a when a.Length <= 10: return a.Length; case ICollection<T> c when c.Count <= 10: return c.Count; case IReadOnlyCollection<T> c when c.Count <= 10: return c.Count; default: throw new ArgumentException("Too large sequence"); } } |
The when keyword only works in a switch. It is not allowed in an if statement, even in the latest C# version:
Discard and Var Patterns
The discard pattern _ matches anything and binds nothing. Use it for the default arm of a switch, or as a placeholder inside other patterns where you do not care about a value. One quirk to file away: a bare _ is allowed in a switch expression, but in an is expression or a switch statement you have to spell it var _ instead.
The var pattern (C# 7) is a different animal. It matches anything, including null, and binds the matched value to a new variable of inferred type:
|
1 2 3 4 5 |
static void VarPattern() { object o = null; Assert.IsFalse(o is object); Assert.IsTrue(o is var v); // var pattern matches even null } |
Here is my opinion on this one. The var pattern is almost never what you want in production code. The fact that it matches null is a footgun in disguise, and the resulting variable is in an undefined null state. The legitimate use case is roughly one: capturing values inside other patterns when anonymous types make naming impossible. Outside that, prefer a plain assignment. Reviewers should not have to remember which is the var pattern and which is a type-inferred declaration.
Switch Expressions: the Best Thing C# 8 Shipped
The switch expression (C# 8) is the feature that made me change how I write decision logic. It is an expression, not a statement, so it returns a value:
|
1 2 3 4 5 6 7 |
static void SwitchExpression(Shape shape) { string whatShape = shape switch { Circle c => $"shape is a circle of radius {c.Radius}", Rectangle _ => "shape is a rectangle", _ => "shape is null or not a known type of shape" }; } |
What you gain over a classical switch statement: no case keyword, no break, no temporary variable to hold the result, and (the big one) compile-time exhaustiveness checking. When the compiler can prove some inputs would fall through the end of the switch, it emits a warning:
If you do reach the end at runtime, a SwitchExpressionException is thrown.
Switch expressions pair beautifully with expression-bodied members:
|
1 2 3 4 5 |
public static string WhatShape(this Shape shape) => shape switch { Circle r => $"This is a circle of radius {r}", Rectangle _ => "This is a rectangle", _ => "shape is null or not a known type of shape" }; |
One word of caution. Switch expressions look great with three arms and unreadable with fifteen. When the arm count climbs, extract the logic into named helper methods, or fall back to a switch statement with explicit blocks. I have reviewed PRs with thirty-arm switch expressions across multiple files, and nobody could tell me what they did without running them.
Property Pattern: Matching on Object Members
The property pattern (C# 8) tests the values of one or several properties. Combined with the type pattern it shines:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 |
static void PropertyPattern(Shape shape) { if(shape is Circle { Radius: 1.0d }) Console.WriteLine($"shape is a circle of radius 1"); string whatShape = shape switch { Circle { Radius: 1.0d } => "shape is a circle of radius 1", Rectangle r when r.Width == r.Height => "shape is a square", Rectangle { Width: 10d, Height: 5d} => "shape is a 10 x 5 rectangle", Rectangle { Width: var x, Height: var y } => $"shape is a {x} x {y} rectangle", { } => "shape is not null", _ => "null" }; } |
A couple of points that trip people up:
- The var pattern inside a property pattern is the legitimate use case for var. Here it captures a property value into a local.
- A property pattern requires the receiver to be non-null. The empty braces form
{ }is therefore a fancy not-null check. You can even bind it, as inobj is { } notNull, to get a non-null variable without naming a type.
Extended Property Pattern (C# 10): Dot Access
Before C# 10, matching on a nested property meant nesting braces. C# 10 added dot notation, which is a small change with an outsized impact on readability:
|
1 2 3 4 5 6 7 8 9 10 |
public static IEnumerable<Person> PersonsWithShortNameCSharp9(this IEnumerable<object> seq) => seq .Where(x => x is Person { FirstName: { Length: <= 5 } }) .Cast<Person>(); // C# 10 extended property pattern with dot access public static IEnumerable<Person> PersonsWithShortNameCSharp10(this IEnumerable<object> seq) => seq .Where(x => x is Person { FirstName.Length: <= 5 }) .Cast<Person>(); |
Resist the temptation to chain four or five dots. By the time you write { Order.Customer.Address.City.Length: > 0 }, you are violating the Law of Demeter and writing code nobody can refactor.
Positional Pattern: Matching via Deconstruction
The positional pattern (C# 8) uses an object’s Deconstruct method (see Deconstruction in C#) or its positional record syntax to match by position rather than by property name:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
public class Rectangle : Shape { public double Height { get; set; } public double Width { get; set; } public override double Area => Height * Width; public void Deconstruct(out double width, out double height) { width = Width; height = Height; } } public static string WhatShapeWithPositionalPattern(this Shape shape) => shape switch { Circle r => $"This is a circle of radius {r}", Rectangle(var x, var y) => $"shape is a {x} x {y} rectangle", _ => "shape is null or not a known type of shape" }; |
Positional patterns work best with C# records, where the deconstructor is generated for you and parameter order is part of the public API. Hand-written Deconstruct methods on mutable classes are a source of bugs (was width first, or height?), so I rarely write them for that purpose. One rule the compiler enforces without mercy: the order of the elements in the pattern must match the order of the out parameters in Deconstruct, because the generated code calls that method directly.
Tuple Pattern: Multiple Values, One Decision
The tuple pattern (C# 8) tests several values at once by wrapping them in a tuple. It shines when the decision depends on a combination of inputs:
|
1 2 3 4 5 6 7 |
public static string ColorsOfWithTuplePattern(Color c1, Color c2, Color c3) => (c1, c2, c3) switch { (Color.Blue, Color.White, Color.Red) => "France", (Color.Green, Color.White, Color.Red) => "Italy", (Color.Black, Color.Red, Color.Yellow) => "Germany", _ => throw new ArgumentException("Unknown flag") }; |
This is also the cleanest way to model a state-machine transition table in C#, with the current state and the incoming event as the tuple. The values are wrapped in a C# ValueTuple.
Logical Patterns: and, or, not
The logical patterns (C# 9) compose simpler patterns without forcing you to repeat the variable name:
|
1 2 |
public static bool IsLetterOrSeparator(this char c) => c is (>= 'a' and <= 'z') or (>= 'A' and <= 'Z') or '.' or ','; |
The example also illustrates the parenthesized pattern (C# 9). The parentheses are optional, but they pin down precedence and make the code readable. They matter more than they look: the combinators bind in the order not, then and, then or, so c is not >= 'a' and <= 'z' does not mean what most people expect. Use the parentheses.
The not pattern is the basis of the is not null idiom we already saw. For more on why the C# team added these keywords, see the design motivation behind the and, or, not keywords.
Relational Patterns: <, >, <=, >=
Relational patterns (C# 9) compare against a constant. They turn ladder-shaped else if chains into something compact:
|
1 2 3 4 5 6 |
public static double GetTaxRateWithRelationalPattern(double monthlyIncome) => monthlyIncome switch { >= 0 and < 1000 => 0, < 5000 => 10, _ => 20 }; |
Combined with property patterns they get even better:
|
1 2 3 4 5 6 7 8 9 10 11 |
public static string WhichShapeWithRelationalPattern(this Shape shape) => shape switch { Circle { Radius: > 1 and <= 10} => "shape is a well sized circle", Circle { Radius: > 10 } => "shape is a too large circle", // Before C# 9 the underscore was required: Rectangle _ => Rectangle => "shape is a rectangle", // The catch-all still needs the underscore _ => "shape doesn't match any pattern" }; |
Notice the small C# 9 quality-of-life change: when you only care about a type, the discard after the type name is no longer required. One gotcha worth remembering: a relational pattern never matches null or a value that fails to convert to the constant’s type, so you still need an explicit arm for those.
List Pattern and Slice Pattern
The list pattern (C# 11) is the most expressive pattern matching addition since C# 8. It tests a sequence element by element. array is [1, 2, 3] matches an array of length 3 with those exact values.
Two helpers do the heavy lifting:
- The slice pattern
..matches zero or more elements. - The discard pattern
_matches a single element.
So:
[_, >0, ..]is shorthand forarray.Length >= 2 && array[1] > 0.[.., <=0, _]is shorthand forarray.Length >= 2 && array[^2] <= 0.
A list pattern can contain at most one slice. Two slices to search for an element anywhere in an array is not allowed, and the compiler is clear about it:
A typical use is small-arity algorithms where you want named locals for the first few elements:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
double Sum(ReadOnlySpan<double> vector) { switch (vector) { case []: return 0; case [double x]: return x; case [double x, double y]: return x + y; default: double sum = 0; for (int i = 0; i < vector.Length; ++i) { double v = vector[i]; sum += v; } return sum; }; } |
The input to a list pattern can be anything that is both countable (has a Length or Count property) and indexable (supports [int], and the ^ end-relative form from the index and range operators). That covers arrays, List<T>, Span<T>, ReadOnlySpan<T>, string, and any custom type that exposes the right members:
|
1 2 3 4 5 6 7 8 9 10 |
string CountryCode(string s) { switch (s) { case [char c0, char c1]: // really tests s.Length == 2 return c0 == 'F' && c1 == 'R' ? "France" : c0 == 'U' && c1 == 'S' ? "USA" : "Other"; default: return "Unknown"; }; } |
List patterns are recursive. The elements of a list pattern can themselves be patterns, including nested list patterns:
|
1 2 3 |
bool ContainsAtLeastOneListAndEndWithASingleIntList(List<List<int>> listOfList) { return listOfList is [.., [_]]; } |
This recursive nature is what makes list patterns so good at parsing semi-structured data, like CSV rows where some lines carry more columns than others. You match on the shape of the line instead of writing index arithmetic by hand.
The Allocation Trap You Need to Know About
Here is a subtlety that catches people. The var pattern can capture the result of a slice, as in [.. var nMinusOneFirstElems, 37]. Do not do this in hot paths. The compiler will emit a hidden array allocation to materialize the slice:
The same hidden allocation hits strings:
If you are processing millions of items, that allocation per match is a hot-path killer. Either drop the slice capture, or switch to Span<T> so the compiler can elide the allocation.
Pattern Matching Performance: When the Compiler Actually Wins
Pattern matching is not just syntactic sugar. There are cases where the compiler rewrites a pattern into IL that a hand-written boolean expression does not get. Compare:
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
private static bool Impl1(short statusCode) => statusCode == 0x1000 || statusCode == 0x1001 || statusCode == 0x1002 || statusCode == 0x2000 || statusCode == 0x2001 || statusCode == 0x3000; private static bool Impl2(short statusCode) => statusCode is 0x1000 or 0x1001 or 0x1002 or 0x2000 or 0x2001 or 0x3000; private static bool Impl3(short statusCode) => statusCode switch { 0x1000 or 0x1001 or 0x1002 or 0x2000 or 0x2001 or 0x3000 => true, _ => false }; |
Decompiled with ILSpy:
|
1 2 3 4 5 6 7 8 9 10 11 12 |
internal static bool Impl1(short statusCode) { return statusCode == 4096 || statusCode == 4097 || statusCode == 4098 || statusCode == 8192 || statusCode == 8193 || statusCode == 12288; } internal static bool Impl2(short statusCode) { return ((uint)(statusCode - 4096) <= 2u || (uint)(statusCode - 8192) <= 1u || statusCode == 12288); } internal static bool Impl3(short statusCode) { return ((uint)(statusCode - 4096) <= 2u || (uint)(statusCode - 8192) <= 1u || statusCode == 12288); } |
The two pattern matching variants get a range-based comparison the plain || chain does not. That said, the JIT is good at this kind of thing too, so the runtime delta is usually negligible. Never trust a microbenchmark argument made by a blog post. Run your own measurements with BenchmarkDotNet on the hot path that actually matters.
Known Limitations of C# Pattern Matching
Eight years in, the syntax still has rough edges:
- Only compile-time constants can appear inside a pattern. Configuration values, database values, and runtime computations cannot. This is the single biggest reason pattern matching is not used more in business code.
- The
whenclause is restricted toswitch. You cannot attach awhento anif. - The slice pattern can appear at most once in a list pattern.
- List patterns with captured slices can trigger hidden allocations.
- Exhaustiveness checking is best-effort. A switch expression over a closed hierarchy of classes is not provably exhaustive to the compiler the way an F# discriminated union would be.
When Not to Use C# Pattern Matching
Most tutorials introduce pattern matching with a type pattern over a shape hierarchy. That is, with respect, the worst possible first example. It sells you on the syntax while quietly demonstrating an anti-pattern.
Consider:
|
1 2 3 4 |
public static double Area(this Shape shape) => shape switch { Circle c => c.Radius * c.Radius * Math.PI, Rectangle r => r.Width * r.Height }; |
Every new shape you ever add forces a hunt for every such switch in the codebase. That is the textbook violation of the Open/Closed Principle. Worse, the virtual method table is faster than repeated type-compatibility checks at runtime. An abstract Area property on the Shape base class wins on both maintainability and performance.
Pattern matching earns its place when:
- The types you switch on are unrelated by inheritance. The
Countexample earlier is a good one. - You are decoding structured data, like a parser or a state machine.
- You are writing a leaf piece of conditional logic over primitives, where polymorphism would be overkill.
If you find yourself adding a third type pattern arm to a switch on a domain object, stop and ask whether the right answer is a virtual method.
C# Pattern Matching by Version
For readers checking what is available in their target framework:
- C# 7:
isoperator extended, null pattern, constant pattern, type pattern, declaration pattern, discard pattern, var pattern,whenclause. - C# 8: switch expression, property pattern, positional pattern, tuple pattern.
- C# 9: logical patterns (
and,or,not), parenthesized pattern, relational patterns. - C# 10: extended property pattern (dot notation for nested members).
- C# 11: list pattern and slice pattern.
- C# 12, 13, 14: no new pattern matching syntax. See C# 12 new features, C# 13 new features, and C# 14 new features.
- C# 15 (preview): union types are on the way, and when the value under test is a union the pattern automatically unwraps it to the underlying value. It is documented but not yet a shipped, supported feature, so treat it as something to watch rather than to lean on in production.
The C# language design discussions for these features are public on the dotnet/csharplang repository if you want the design history.
C# Pattern Matching FAQ
What is pattern matching in C#?
Pattern matching in C# is a feature that tests whether an expression has a specific shape (a type, a value, a property, a structure) and optionally extracts data from it. Patterns work with the is operator, switch statements, and switch expressions.
When was pattern matching introduced in C#?
Pattern matching arrived in C# 7.0 (2017) with the null, constant, type, discard, and var patterns. Every C# version up to C# 11 added new pattern syntax. C# 12, 13, and 14 added nothing new in this area.
What is the difference between a switch statement and a switch expression?
A switch statement is a control-flow construct that executes one of several blocks of code. A switch expression, added in C# 8, is an expression that produces a value. Switch expressions are more compact, support compile-time exhaustiveness checking, and integrate cleanly with expression-bodied members.
What is the difference between the declaration pattern and the type pattern?
The declaration pattern x is Circle c checks the run-time type and binds a typed variable. The type pattern x is Circle only checks the type and binds nothing; before C# 9 you had to write Circle _ for that. Both reject null.
How do I check for null using pattern matching in C#?
Use x is null for the null check and x is not null (C# 9 and later) for the negated form. These always perform a real reference comparison and ignore any user-defined operator ==. Prefer them over == null and != null.
Is C# pattern matching faster than if-else?
Usually the same, sometimes faster. The compiler applies optimisations to pattern matching that it does not apply to equivalent || chains, such as range-based comparisons for constant sets. Always measure with BenchmarkDotNet before treating any specific case as a perf win.
Can I match against a non-constant value in C#?
No. Pattern values must be compile-time constants. Workarounds: use a when clause inside a switch statement, or fall back to a regular if. This restriction is the main reason pattern matching is not used more in business logic.
Which C# version introduced list patterns?
List patterns and the slice pattern .. arrived in C# 11. They match a sequence element by element and work on any type that is both countable (Length or Count) and indexable ([int]), including arrays, List<T>, Span<T>, and string.
Does pattern matching work with records in C#?
Yes, and records are the ideal target for positional patterns. A positional record generates a Deconstruct method for you, so you can match by position with syntax like point is (0, 0) and keep parameter order as part of the public API.
Should I use the type pattern instead of polymorphism?
No. If your types share a base class or interface, polymorphism is the right tool. Use the type pattern for types unrelated by inheritance (like the IEnumerable<T> / ICollection<T> / Array example in this article), for parsing, and for state machines.
Conclusion
Pattern matching is to if/else what LINQ is to for/foreach: a more concise way to write the same logic, sometimes with better generated code. Almost a decade later with a new C# version released per year, it has matured into a feature you can trust in production.
The one habit to break is treating the type pattern as a replacement for polymorphism. It is not. Keep it for the cases where polymorphism does not apply, and your codebase will thank you.
The one wish I still have is non-constant matching. The C# team has not shipped it, and I understand why (exhaustiveness, side effects), but a story for that would unlock a lot of business-logic use cases that currently live in plain if chains.









Thanks for the clear summary! It’s nice to see how well C# is coming along, and I’m looking forward to playing around with the newer pattern types soon.