Back to Blog
C#

c# type pattern: Syntax and Usage

Learn how to use the c# type pattern with is expressions and switch statements for type testing and property matching.

type patternspattern matchingC# switchis expressionrecursive patterns
A flowchart-like visual showing an object being matched to multiple type branches in C#.

The c# type pattern is a way to match an object against a type and optionally bind the matched value to a new variable. It appears in is expressions and switch statements or expressions, and it replaces many verbose casts and null checks.

Consider the classic approach: if (obj is string) followed by (string)obj to use the value. The type pattern compresses this into if (obj is string s), where s is the typed reference. This works for reference types, nullable value types, and non-nullable value types.

object value = "hello"; if (value is string message) { Console.WriteLine(message.Length); }

Here the pattern succeeds if value is not null and is of type string (or a type compatible with string). For nullable value types, the pattern matches when the value is not null, and the declared variable receives the underlying value type.

int? maybe = 5; if (maybe is int number) { Console.WriteLine(number); }

This is equivalent to maybe.HasValue and maybe.Value, but it reads more cleanly. The variable number is an int, not int?.

Using Type Patterns in Switch Statements

The type pattern is most useful in switch statements and switch expressions when you need to branch on the runtime type of an object. Instead of chaining if-else checks with explicit casts, each case can test a type and bind a local variable.

static string DescribeShape(object shape) { switch (shape) { case Circle c: return $"Circle with radius {c.Radius}"; case Rectangle r: return $"Rectangle {r.Width} x {r.Height}"; default: return "Unknown shape"; } }

Each case uses the type pattern. The variable declared in the pattern is strongly typed within that case, eliminating the need for an explicit cast. The default case handles any other type.

Switch expressions allow a more compact form:

static string DescribeCircle(object shape) => shape switch { Circle c => $"Radius: {c.Radius}", _ => "Not a circle" };

Note that _ is a discard pattern, not a variable. It matches anything, and its value is ignored.

Type Patterns with Inheritance and Interfaces

Type patterns respect inheritance and interface implementation. A pattern Base b matches any object that is a Base or a derived type. This is expected, but it affects how you order cases when a hierarchy exists.

class Animal { } class Dog : Animal { } static string Classify(Animal animal) { return animal switch { Dog d => "Dog", Animal a => "Animal" }; }
// If Dog comes after Animal, Dog is unreachable and the compiler warns.

Because Animal matches all instances, placing it first makes the Dog case unreachable. The compiler will warn about this. Order cases from more specific to more general.

For interfaces, the same principle holds. A pattern that matches an interface will match any implementation. In C# 9 and later, you can use relational patterns to test properties, but simple type patterns remain the core mechanism.

Combining Type Patterns with Property and Positional Patterns

The type pattern is often combined with property patterns to inspect properties of the matched object. In a switch expression, you can write Type var when condition, or use nested patterns.

static string GetAreaMessage(object shape) => shape switch { Circle { Radius: > 0 } c => $"Area: {Math.PI * c.Radius * c.Radius}", Rectangle { Width: var w, Height: var h } => $"Area: {w * h}", _ => "Unknown" };

Here the pattern Circle { Radius: > 0 } c matches a Circle with a positive radius and binds c. The Radius property pattern uses a relational pattern > 0. The Rectangle case uses property patterns that bind the property values to variables w and h.

Positional patterns work with types that have a Deconstruct method, and they are useful when you want to decompose a tuple or a custom type.

record Point(int X, int Y); static int GetX(object obj) => obj switch { Point(var x, _) => x, _ => 0 };

The Null Check and the Negated Pattern

A type pattern only matches non-null values. If obj is null, the pattern fails. This is important when you are switching on a variable that could be null.

object? maybe = null; if (maybe is string s) { // This branch is not taken for null. } else { // This branch handles null and other types. }

If you need to match a non-null value of a specific type, the type pattern is sufficient. To explicitly test for null, use the constant pattern null. You can also use the negation pattern with not to exclude a type.

var result = obj switch { null => "null", string s => s, not string => "not a string" };

The not string pattern matches anything that is not a string, including null. Because the first case handles null, the last case covers only non-null non-string objects.

Type Patterns and Nullable Value Types

When the input is a nullable value type such as int?, the type pattern binds the underlying value type. This is a convenient way to avoid repeated HasValue checks.

static int GetValueOrDefault(object obj) => obj switch { int number => number, long big => (int)big, _ => 0 };

Because int? is a struct, the boxed value is either int or null. Therefore, obj is int number works. For a non-boxed int?, you can also use the type pattern directly in an is expression, as shown earlier.

Performance and Allocation Considerations

Type patterns use isinst and castclass IL instructions internally. They do not create additional allocations in most cases. The CLR performs a type check and then supplies the reference or value. For value types, the declared variable is the unboxed value, which can be more efficient than an explicit unboxing operation that throws if the type is wrong.

One subtle performance point is that property patterns may require property getter calls. For Point(var x, _), the Deconstruct method runs. If the type is a record, the compiler generates a Deconstruct method that reads properties. This is usually cheap, but for properties that do heavy work, the cost appears each time the pattern is evaluated.

Another consideration is that switch statements compile to a lookup table when the cases are constant patterns, but for type patterns they compile to a sequence of type tests. The order of cases matters for performance. Placing the most likely type first reduces the average number of checks.

Compatibility and Language Version Requirements

Type patterns were introduced in C# 7.0. Property patterns, positional patterns, and switch expressions require C# 8.0 or later. Relational patterns are C# 9.0. The not pattern also appears in C# 9.0. If you are targeting an older language version, the compiler will produce an error.

In older C# versions, you can still use is with a type check but without the variable declaration. For example, if (obj is string) works in C# 6, but if (obj is string s) requires C# 7.

Another restriction: type patterns do not match null, so a null input falls through all type cases. This is by design, but it means you need an explicit null case if you want to handle null separately.

Choosing Between Type Patterns and Traditional Casts

Type patterns should be used when you need to test the runtime type and then use the typed value. They produce cleaner code and eliminate the repeated cast and null check. For example, instead of:

if (obj is Base b) { /* use b */ }

You would otherwise write:

if (obj is Base) { var b = (Base)obj; // use b }

The pattern is less error-prone because the cast is guaranteed to succeed if the pattern matched.

Use the pattern when you are branching over several distinct types. A chain of if-else with casts becomes harder to read and more error-prone as the number of types grows. The switch form also gives compiler support for exhaustiveness checks in switch expressions.

However, if you only want to check whether an object implements an interface without using the variable later, the simple is expression is enough. For example:

if (obj is IDisposable) { // do nothing with the reference }

Deciding between the type pattern and other patterns comes down to what you need from the matched value. If you need the typed reference, use a type pattern. If you also need to inspect properties, combine it with property patterns. If you only need a boolean check, the is expression returns true or false without a variable.

Common Pitfalls with Type Patterns

A common mistake is assuming that a type pattern binds a variable that is usable after the is expression. In an is expression, the pattern variable is in scope only within the if statement's true branch. It is not accessible after the if block.

if (obj is string s) { Console.WriteLine(s.Length); // correct } // s is not in scope here

Another pitfall is using the pattern on an object that is a nullable value type. If you write if (value is int?), that pattern matches when value is not null, but the bound variable is int?, not int. This can lead to unexpected null handling.

int? maybe = 5; if (maybe is int? n) { // n is int?, not int }

Prefer int as the pattern type when you want the underlying non-nullable value.

Using Type Patterns in Expression Trees and Reflection

Type patterns are compiled into IL, so they work in expression trees. However, you cannot use a pattern variable in a lambda that is compiled to an expression tree because pattern variables are not supported in expression lambdas. If you need to call a method that takes an Expression, you cannot use is patterns inside the expression lambda. Instead, you would need to build the expression manually or use a delegate instead of an expression tree.

This limitation rarely appears in normal application code, but it matters for libraries that accept expression trees, such as some query providers.

Integration with Records and Deconstruction

Records provide a natural fit for positional patterns because the compiler generates Deconstruct methods for positional records. This allows you to match on the record's fields directly.

record Person(string Name, int Age); static string Greet(object obj) => obj switch { Person("Admin", _) => "Welcome, admin", Person(var name, var age) when age >= 18 => $"Welcome, {name}", Person(var name, _) => $"You are a minor, {name}", _ => "Not a person" };

Here, the first case uses a constant pattern for the first positional element and a discard for the second. The second case uses when to add a condition. This combination lets you express complex routing rules concisely.

When using positional patterns, the number of arguments must match the Deconstruct parameter count. If your type has a custom Deconstruct method with a different number of output parameters, you must match that count.

How Compiler Handles Exhaustiveness

In a switch expression, the compiler can detect when the set of patterns covers every possible runtime type. For example, if you switch on an object, you need a null case and a discard _ to be exhaustive. If you switch on a sealed class hierarchy where you have cases for every derived type and also handle null, the compiler may still warn about missing cases if you use an interface.

The exhaustiveness analysis is based on the static type of the input and the patterns. For an object input, a type pattern for string and a type pattern for int are not exhaustive because there are many other possible types. The compiler will report a warning if you do not provide a _ or null case. Adding a _ is the safest way to satisfy the compiler and handle unexpected types at runtime.

A practical implication is that when you add a new derived type to a hierarchy, the compiler does not force you to update a switch expression unless you use the default case or the _ discard. Without a _, the switch expression throws an InvalidOperationException at runtime when no pattern matches. Using a _ makes the behavior explicit and avoids accidental exceptions.

Type Pattern in Guard Clauses and Early Returns

Type patterns are useful in guard clauses to fail fast. Instead of performing a cast and then checking null, you can write a single condition that binds the typed variable and returns early if the type is wrong.

static void Process(object input) { if (input is not string text) { throw new ArgumentException("Expected a string", nameof(input)); } // text is a non-null string here }

Here, is not string text is the negation of a type pattern. It matches when input is null or not a string. If the pattern fails, the variable text is not assigned. This pattern is common for validating method parameters.

In the success branch, text is guaranteed to be a non-null string. This reduces the number of if (input != null) checks and makes the null handling explicit.

Where Type Patterns Do Not Help

Type patterns do not perform conversion. If you need to convert a double to an int, a cast is required. The pattern only matches if the runtime type is exactly the specified type. For numeric conversions, you need a separate step.

Similarly, type patterns do not perform user-defined conversions. If a class defines an implicit conversion to string, obj is string will not match because the runtime type is not string.

The pattern does not trigger boxing or unboxing beyond what is required to store the value. When matching a value type, the variable holds the unboxed value. This is efficient but can be confusing if you expect a reference to the boxed object.

These limitations are inherent to pattern matching: it is designed for type testing, not for conversion.

When to Favor a Custom Method Over Type Patterns

If you find yourself writing many type patterns for the same object multiple times, consider encapsulating the logic in a method or using polymorphism. A switch on types can become a maintenance burden when the type hierarchy changes often. In such cases, a virtual method on the base class is often a better abstraction.

abstract class Shape { public abstract string Describe(); } class Circle : Shape { public override string Describe() => "Circle"; } class Rectangle : Shape { public override string Describe() => "Rectangle"; }

Using virtual methods places the behavior alongside the data and makes it easy to add new shape types. Type patterns are most valuable when you cannot modify the type hierarchy, such as when working with third-party types or primitive objects.

The decision between patterns and polymorphism depends on whether the set of types is closed (you control the hierarchy) or open (you cannot add methods). If the hierarchy is closed and you rarely add new types, type patterns are often simpler. If the hierarchy is open, polymorphism is more maintainable.

A tradeoff is that type patterns can be more expressive for one-off routing logic, while polymorphism is better suited to core domain behavior that changes rarely. Choose based on where the logic is likely to change.

c# type pattern: Syntax and Usage for Type Matching | RYUSLOG DEV