C# is Operator Usage: Type Checking and Pattern Matching
c# is operator usage: Learn how to use the C# `is` operator for type checking and pattern matching, including practical examples, comparisons with `as` and casts, and...
The is operator in C# is often introduced as a simple type-checking tool, but modern C# has turned it into a full-featured pattern-matching engine. Understanding c# is operator usage beyond the basic if (obj is MyType) form can make your code more expressive, safer, and easier to maintain. This article covers the operator's syntax, its pattern-matching capabilities, and the tradeoffs you should consider when choosing between is, as, and explicit casts.
The Basic Type-Checking Form of is
The simplest form of the is operator checks whether an expression matches a given type. It returns true if the runtime type of the expression is compatible with the specified type, and false otherwise. The expression can be any value, including a reference type, a nullable value type, or a boxed value.
object value = "Hello"; if (value is string) { Console.WriteLine("The value is a string."); }
In this example, value is string evaluates to true because the runtime type of value is string. The is operator does not throw an exception if the type does not match; it simply returns false. This makes it a safe way to test a type before performing a cast or invoking a type-specific operation.
A common pattern is to combine the type check with a variable declaration using the is operator's pattern form:
if (value is string text) { Console.WriteLine($"The string length is {text.Length}."); }
Here, if value is a string, the variable text is assigned the value and is in scope inside the if block. This avoids a separate cast and reduces the chance of a NullReferenceException because the pattern only matches when the value is not null (for reference types) and is of the specified type.
Pattern Matching Beyond Type Checks
The is operator supports several pattern forms that go far beyond simple type testing. These patterns allow you to check constants, relational conditions, and logical combinations in a concise way.
Constant Patterns
A constant pattern checks whether an expression equals a constant value. This is useful for comparing against null, numeric literals, or enum values.
int number = 42; if (number is 42) { Console.WriteLine("The number is 42."); }
The constant pattern also works with null:
if (value is null) { Console.WriteLine("The value is null."); }
Note that value is null is equivalent to checking ReferenceEquals(value, null) for reference types, but it also works for nullable value types.
Relational Patterns
Relational patterns use comparison operators (<, >, <=, >=) to test a value against a constant. This is especially useful for range checks.
int score = 85; if (score is >= 90) { Console.WriteLine("Grade: A"); } else if (score is >= 80) { Console.WriteLine("Grade: B"); }
Relational patterns are often combined with logical patterns to express more complex conditions.
Logical Patterns
The and, or, and not keywords combine patterns. For example, you can check that a value falls within a specific range:
if (score is >= 70 and < 80) { Console.WriteLine("Grade: C"); }
The not pattern is useful for negating a pattern, such as checking that a value is not null:
if (value is not null) { Console.WriteLine("The value is not null."); }
These logical patterns make the is operator a compact alternative to multiple && and || conditions, especially when combined with type patterns.
Property, Positional, and Var Patterns
Modern C# supports richer patterns that inspect an object's properties or deconstruct its structure.
Property Patterns
A property pattern matches when an object's property satisfies a nested pattern. This is extremely useful for validating objects without writing verbose if chains.
if (user is { IsActive: true, Role: "Admin" }) { Console.WriteLine("Active admin user."); }
Here, the pattern checks that user is not null, user.IsActive is true, and user.Role equals "Admin". If any condition fails, the whole pattern returns false. Property patterns can be nested to arbitrary depth.
Positional Patterns
Positional patterns rely on deconstruction. If a type defines a Deconstruct method, you can match its components positionally.
public readonly struct Point { public int X { get; } public int Y { get; } public Point(int x, int y) => (X, Y) = (x, y); public void Deconstruct(out int x, out int y) => (x, y) = (X, Y); } Point p = new(3, 4); if (p is (0, 0)) { Console.WriteLine("Origin"); } else if (p is (var x, var y)) { Console.WriteLine($"Coordinates: ({x}, {y})"); }
The first pattern (0, 0) uses constant patterns inside a positional pattern. The second uses var patterns to capture the values into variables.
Var Patterns
The var pattern always matches and assigns the value to a new variable. It is often used to introduce a variable for later use in the same expression.
if (value is var result) { // result is the same as value, but now a named variable }
In practice, var patterns are most useful in combination with other patterns, such as in a switch expression where you need to capture the matched value.
Practical Usage: When is Improves Code Readability
The is operator shines in scenarios where you need to branch based on the runtime type of an object, especially when combined with pattern matching. Consider a method that processes different shapes:
public static double CalculateArea(Shape shape) { if (shape is Circle { Radius: var r }) return Math.PI * r * r; if (shape is Rectangle { Width: var w, Height: var h }) return w * h; if (shape is Triangle { Base: var b, Height: var h }) return 0.5 * b * h; throw new ArgumentException($"Unknown shape type: {shape.GetType()}"); }
Each is pattern checks the type and extracts the relevant properties in one line. Without pattern matching, you would need a series of if (shape is Circle) blocks followed by casts and separate property access, which is more verbose and error-prone.
The is operator also integrates with switch expressions, making it a natural fit for type-based dispatch:
public static string Describe(object value) => value switch { int i when i > 0 => $"Positive integer {i}", int i => $"Integer {i}", string s => $"String: {s}", null => "Null", _ => "Unknown" };
Here, the switch expression uses the same pattern syntax as is, including a when guard for additional conditions. This is a clear, concise way to handle multiple types without nested if statements.
Comparing is, as, and Explicit Casts
When you need to work with an object as a specific type, you have three main options: the is operator (with a pattern), the as operator, and an explicit cast. Each has different behavior and use cases.
| Approach | Syntax | Behavior | Null Handling | Use Case |
|---|---|---|---|---|
is with pattern | if (obj is T t) | Returns true and assigns t if obj is of type T; otherwise false. | Does not match if obj is null (for reference types). | Safe type checking and extraction in a single step. |
as operator | T t = obj as T; | Returns null if obj is not of type T or is null. | Always returns null for null input. | When you need a nullable result and want to avoid exceptions. |
| Explicit cast | T t = (T)obj; | Throws InvalidCastException if obj is not of type T. | Throws NullReferenceException if obj is null and T is a reference type. | When you are certain of the type and want to fail fast. |
The is operator is the safest because it combines a type check with a conditional assignment. The as operator is useful when you want to attempt a conversion and then check for null. Explicit casts should be reserved for cases where you are confident about the type and want an exception if the assumption is wrong.
Consider this example:
object data = GetData(); // Using is if (data is string message) { Console.WriteLine(message.ToUpper()); } // Using as string? maybeMessage = data as string; if (maybeMessage != null) { Console.WriteLine(maybeMessage.ToUpper()); } // Using cast (throws if not a string) try { string castMessage = (string)data; Console.WriteLine(castMessage.ToUpper()); } catch (InvalidCastException) { // Handle failure }
The is version is the most concise and avoids the extra null check. The as version is slightly more verbose but can be useful when you need to reuse the converted value outside the if block. The explicit cast is only appropriate when you are absolutely certain of the type.
Performance and Runtime Behavior of is
The is operator performs a runtime type check. For reference types, this is a simple type identity or inheritance check, which is very fast. For value types, the check may involve unboxing if the object is boxed. However, the cost is generally negligible compared to the cost of a failed cast or an exception.
One important performance consideration is that pattern matching with is can avoid the overhead of a separate cast. For example, the pattern obj is string s is equivalent to checking the type and then casting, but it does so in a single operation. The C# compiler often optimizes this to a single type check and a direct reference assignment, avoiding a second cast instruction.
Another subtlety is that the is operator does not perform user-defined conversions. It only checks the runtime type. If you have a custom implicit conversion operator, is will not use it. This is different from casting, which can invoke user-defined conversions. For example:
class Celsius { public double Degrees { get; set; } public static implicit operator Fahrenheit(Celsius c) => new Fahrenheit { Degrees = c.Degrees * 9 / 5 + 32 }; } Celsius c = new() { Degrees = 20 }; if (c is Fahrenheit f) // Always false, even though an implicit conversion exists { // This block never executes }
This behavior is intentional: is is designed for type testing, not conversion. If you need to apply a conversion, use a cast or a separate method.
In terms of memory, is does not allocate. It does not box value types unless the input is already boxed. When you use is on a boxed value type, the pattern matching may unbox it, which has a small cost but does not create a new object.
For performance-critical code, the main advice is to avoid using is in a hot loop when a simpler type check would suffice, but in practice the overhead is minimal. The bigger win is often the readability and safety that pattern matching provides, which can reduce bugs and improve maintainability.
Common Pitfalls and Edge Cases
Several edge cases can trip up developers new to the is operator.
Nullable Value Types
When checking a nullable value type, the is operator behaves as you might expect: int? x = 5; if (x is int) returns true and the pattern x is int i gives i as an int (not int?). If x is null, the pattern does not match. This is convenient because it avoids the HasValue check.
int? maybeNumber = 10; if (maybeNumber is int number) { Console.WriteLine(number + 1); // Works, number is int }
Inheritance and Interfaces
The is operator respects inheritance and interface implementation. If a class implements an interface, obj is IInterface returns true for instances of that class. This is the same behavior as a cast.
The not Pattern with null
A common idiom is if (value is not null) to check for non-null. This is clearer than if (value != null) because it works consistently with pattern matching and is more readable in complex expressions.
Type Patterns with var
A type pattern like value is var x always matches, but it does not perform a null check. If value is null, x will be null as well. This is different from value is string s, which only matches non-null strings. Be careful not to confuse var patterns with type patterns.
Using is with Generic Type Parameters
For generic type parameters, is can be used to check if a value matches a specific type, but you need to be aware of boxing. For example:
public static bool IsString<T>(T value) => value is string;
If T is a value type, value will be boxed when passed to the method, and the is check will unbox it. This works correctly but may have a small performance cost. In generic code, consider using typeof(T) comparisons or equality comparers if you need to avoid boxing.
Pattern Matching in switch Statements
The is operator is closely related to switch statements and expressions. In fact, a switch with type patterns is often more readable than a series of if statements. However, be aware that the order of cases matters: the first matching case wins. This can lead to subtle bugs if you have overlapping patterns. Always place more specific patterns before more general ones.
Choosing the Right Pattern for the Job
The is operator is not a one-size-fits-all tool. Deciding which pattern to use depends on the exact condition you need to express.
- Use a type pattern (
obj is T t) when you need to check the type and access members ofT. - Use a constant pattern (
obj is nullorobj is 42) when you need to compare against a constant. - Use a relational pattern (
obj is > 0) when you need to compare against a range. - Use a property pattern (
obj is { Prop: value }) when you need to validate multiple properties without nestedifblocks. - Use a positional pattern (
obj is (x, y)) when the type supports deconstruction and you want to extract components. - Use a
varpattern when you need to capture the value for later use, but be aware that it always matches.
In many cases, a combination of these patterns is the clearest way to express a condition. For example, checking that a value is a non-null string with a length greater than 5 can be written as:
if (value is string { Length: > 5 } s) { Console.WriteLine($"Long string: {s}"); }
This single expression combines a type pattern, a property pattern, and a relational pattern. It is concise, readable, and avoids multiple if statements or temporary variables.
The is operator has evolved from a simple type-checking keyword into a powerful pattern-matching tool. By understanding its various forms and the tradeoffs between is, as, and casts, you can write C# code that is both safer and more maintainable. The key is to choose the pattern that best expresses the condition you need to check, and to be aware of the runtime behavior and edge cases that come with each form.