AlgoMaster Logo

Type Patterns

Medium Priority27 min readUpdated June 6, 2026

The _Pattern Matching Basics_ lesson introduced the is operator and the switch expression as the two surfaces where pattern matching shows up. This lesson focuses on the patterns that test what an object's _type_ is: constant patterns, type patterns, the var pattern, the discard pattern, and how they combine when you dispatch on a class hierarchy. Property, positional, relational, logical, and list patterns are later lessons in this section, so leave those alone for now and concentrate on type-only matching.

Why Type Patterns Exist

Before pattern matching arrived, dispatching on type meant a chain of is tests followed by casts:

The pattern is the same every time. Check the type with is, cast back to that type, then use it. The cast can't fail (you just checked the type), but the compiler still emits the check, and the source is noisy. Reading the variable name twice (once in the is, once in the cast) is the kind of repetition that's easy to get wrong during a refactor.

Type patterns collapse the check and the cast into one expression. The same code, rewritten:

method and code are real local variables that the compiler binds only inside the branch where the type test passed. Outside that branch they're not in scope, so you can't accidentally use them where the cast hadn't been validated. That's the core ergonomic win, and it's also why type patterns are the foundation every other pattern builds on.

Constant Patterns

The simplest pattern is a literal value. Writing value is 0 or value is "active" tests for exact equality, the same way == would, except it can be used anywhere a pattern is expected, including inside a switch expression.

Constant patterns work with any compile-time constant: numeric literals, string literals, true, false, named constants, and enum members. The match uses the type's == semantics, so 5 is 5.0 is false because the literal 5 is an int and the literal 5.0 is a double, and they aren't the same value.

The _ arm at the end is the discard pattern. It matches anything that didn't match an earlier arm and is the standard way to handle the "everything else" case in a switch expression.

null deserves special mention. value is null is the recommended way to check for null in modern C#, and internally it's a constant pattern.

The reason to prefer is null over == null is that is null always calls the runtime null check; it can't be hijacked by an overloaded == operator on the type. A class that overrides == to compare by value (a common pattern on domain types) might return true when comparing a non-null instance against null, depending on how the override is written. is null ignores the overload and asks the runtime "is this reference null," which is what you usually want.

Bare Type Test vs Declaration Pattern

There are two ways to use a type in an is expression, and they look almost identical. The difference matters.

The bare type test obj is Customer returns true when the runtime type of obj is Customer (or a derived type, more on that shortly). It doesn't introduce a new variable.

The declaration pattern obj is Customer c does the same type test, _and_ introduces a new variable c of type Customer that's in scope inside the branch where the match succeeded.

The bare test is fine when you only need to know whether the object is of that type and you don't need the typed reference (a discriminator method that returns bool, for example). Any time you'll actually use the object as that type, prefer the declaration pattern. It eliminates the cast and the compiler tracks the new variable's scope for you.

The bound variable from a declaration pattern is scoped to the branch where the pattern succeeded. Reading it in the else branch, or after the if block, is a compile error.

The scoping rule is what makes declaration patterns safe. The compiler proves the variable is only in scope where the type check passed, so you can never accidentally dereference an object you haven't validated.

There's one detail about the type test itself. obj is Customer c matches when obj's runtime type is Customer _or any subclass of Customer_. It does not match when obj is null, even if the static type of obj is Customer?. That's almost always the behavior you want: a null reference fails the type test, so the bound variable can be treated as non-null inside the matched branch.

Both Customer and PremiumCustomer match is Customer c because PremiumCustomer derives from Customer. The string and the null both fail.

The type test compiles to roughly the same IL as the old is operator: a single isinst instruction plus a null check. The declaration pattern adds an assignment to the local, which is free. There's no reflection and no boxing of the source reference.

The var Pattern

The var pattern is the odd one. It always matches, no matter what the input is, and it binds the input to a new local variable typed as the input's static type.

That doesn't look useful in isolation. A pattern that always matches is the same as no test at all. The var pattern is useful in two specific situations.

The first is inside a switch expression where you want a catch-all arm that also captures the value, not just discards it.

The var other arm matches anything that the earlier arms didn't, and it binds the value to other so the right-hand side can use it. The plain discard _ would match the same set of values but wouldn't give you a name.

The second use is inside a more complex pattern where you want to capture an intermediate value for use in a when guard. For now, the catch-all use is the one to remember.

There's an important subtlety. The var pattern matches null too. If your input might be null, is var x won't filter it out.

Read that result carefully. The is var test succeeded, and anything is still null. That's the whole behavior of the var pattern: match unconditionally, capture the value as-is. If you want a non-null check, use a declaration pattern or pair var with is not null.

var Pattern vs Declaration Pattern

These two look similar, but they answer different questions.

The declaration pattern is _conditional_: it only succeeds when the type matches, and the bound variable has the matched type. The var pattern is _unconditional_: it always succeeds, and the bound variable has whatever static type the input expression had. If you wrote input is var x and the static type of input is object, then x is typed as object even if the runtime value is a string.

Declaration pattern (is Customer c)var pattern (is var c)
Match when types differFailsSucceeds
Match when value is nullFailsSucceeds
Type of bound variableThe pattern's typeStatic type of input
Typical useFilter and cast in one stepCatch-all that needs a name

A rule of thumb: use the declaration pattern when you want to test _and_ capture. Use the var pattern when you've already filtered with earlier arms and only need a name for the remaining catch-all.

The Discard Pattern

The discard _ is a placeholder that matches anything and binds no name. You've already seen it as the catch-all arm in switch expressions. It also shows up inside larger patterns when you need to match the shape of something but don't care about a specific component.

The arms int and string are bare type tests as patterns. They match any value of that type without binding a variable. The trailing _ catches anything that didn't match, including null and any other runtime type. Don't confuse the discard with the var pattern, though: _ doesn't introduce a variable, var name does. If you need to reference the value on the right-hand side of the arrow, you can't use _.

The bare type pattern (int or string with no name) is technically different from the declaration pattern (int i). It's still a type test, but without the binding. It compiles to the same isinst IL, just with no local to assign into. Use the bare form when the type test is all you need.

Replacing as + Null Check Chains

The pre-pattern-matching equivalent of is X x was the as operator followed by a null check. You'll see this in older codebases.

Both forms work. The declaration pattern wins on three counts. The variable is scoped to the success branch, so it can't be accidentally used outside. There's no intermediate nullable variable polluting the surrounding scope. And the intent is obvious: "if this is a PremiumCustomer, give me one." Modern code should prefer the pattern. Treat as + null check as a legacy form you'll recognize but shouldn't write.

There's one case where as still has a niche: when you want a nullable typed reference that you'll pass around later, not consume immediately.

Even there, the pattern can usually be reorganized so the conditional logic lives where the check happens. The as form fits only when the typed-or-null result legitimately needs to flow through code that the pattern's scope can't reach.

Dispatching on a Product Hierarchy

Type patterns earn their keep when you're dispatching across a class hierarchy. An e-commerce platform sells products that come in different shapes: digital products you download, physical products you ship, and subscription products you renew. They share a base type, and each subtype has fields the others don't.

The job is to render a short fulfillment line for each one. Without pattern matching it's a chain of is + cast. With type patterns it's a single switch expression.

Each arm of the switch expression is a declaration pattern. The bound variable (d, p, s) is in scope only on the right-hand side of that arm, typed as the subtype, so you can read the subtype-specific fields directly. The _ arm catches anything that doesn't fit, which protects against new subtypes being added later without the dispatch being updated.

A diagram makes the dispatch easier to picture.

The compiler doesn't necessarily emit literal sequential isinst checks. For small hierarchies it does, but for larger ones it can build a type lookup table or use the runtime type-handle hash to jump straight to the matching arm. The codegen isn't the focus here; what matters is that one expression replaces a tower of if/else if and stays correct as the hierarchy grows.

Order Matters With Inheritance

Switch arms are tested top to bottom. The first arm that matches wins, and later arms never get a chance. That's a problem when a derived type can match an arm written for its base type.

The intent looks reasonable until you remember that an EBook _is_ a Product, so the first arm matches. The C# compiler catches this for you:

The fix is the standard "most specific first" rule. Order derived types before their bases.

Each arm now only fires when none of the more specific arms above it matched. The compiler doesn't catch every ordering bug (it only warns when an arm is _provably_ unreachable), so getting the order right is still your responsibility. Use the rule: most derived types at the top, base types beneath them, _ at the bottom.

Sealed Hierarchies and Exhaustiveness

When you control the hierarchy and want the compiler to help you keep the dispatch up to date, sealing the base class (or making it abstract with all subtypes in the same assembly) is a useful discipline. The compiler can then warn when a switch expression is missing an arm for a known subtype.

There are two design choices to notice here. Every concrete payment type is sealed, which means the only subclasses of PaymentMethod are the three listed. Anyone reading the code can be confident the switch covers the world. And the catch-all _ arm throws instead of returning a default, which converts a coding mistake (someone added a new subtype but forgot to update this method) into a loud runtime failure during testing.

If you leave the _ arm off entirely, the compiler will warn that the switch isn't exhaustive _for all possible values of PaymentMethod_, because in principle some external code could derive a new subclass. That's why most teams keep the _ arm and have it throw. The combination of sealed subtypes + _ => throw is the practical equivalent of "make this exhaustive and tell me if a future change breaks the assumption."

The switch expression compiles to a dispatch that's typically O(1) for small hierarchies (sequential isinst checks, which JIT can fold into a switch table). The exception-throwing _ arm has zero cost on the happy path; the throw only runs if a new subtype shows up.

Generic Constraints and Type Patterns

Pattern matching also works inside generic methods. The pattern checks against the runtime type of the value, not the static type parameter T, so a constrained generic can still pattern-match against the constraint type.

The generic parameter T is constrained to Product, and inside the body the patterns match against PhysicalProduct and DigitalProduct because those are runtime types of the values. When you call ShippingFee with the static type Product, the patterns still test the actual runtime type, which is PhysicalProduct and DigitalProduct for the two items.

Note the result for Mug: shipping is $2.00, not $2.00 for the catch-all. That's because the call passed Product (the list's element type) and the dispatch landed on the PhysicalProduct arm correctly. The _ arm only runs for a product that's neither physical nor digital. Patterns inside generic methods aren't restricted; they see the same runtime type as patterns in any other method.

One thing to remember about generics and structs. If T is constrained to a value type (where T : struct), boxing happens when you compare against a reference type. That isn't usually a concern with class hierarchies but is worth keeping in mind for value-type generics.

Common Mistakes

A few patterns trip people up the first time.

Redundant cast after the pattern. The variable bound by a declaration pattern is already the matched type. Casting it again is harmless but wasteful and obscures intent.

The fix is to use c directly:

Missing null arm. A switch expression that handles every known subtype but forgets null will throw SwitchExpressionException at runtime when the input is null.

Add an explicit null arm, a _ catch-all, or both, depending on what you want the missing case to do.

Pattern ordering for inheritance. As covered earlier, listing a base type before its derived types makes the derived arms unreachable. The compiler warns when it can prove unreachability, but only when it can prove it. For chained derived types it's still on you to put the most specific first.

Confusing `var` and discard. The arm var x => binds the value to x; the arm _ => doesn't. If you write var _ (a var pattern named _), that's still a binding to a variable literally named _, which is allowed but ugly. Use plain _ for "match anything, no name needed" and use var name when you need the name on the right-hand side.

Treating `var` as a non-null filter. The var pattern matches null. If you want to filter out null _and_ capture, use the declaration pattern (is Type name) or pair var with a guard (is var x when x is not null). Guards are covered in more depth in later lessons.

Putting the Patterns Together

A small end-to-end example brings the patterns together: routing different customer tiers through different greeting logic.

The arms use four different patterns, each pulling its weight. The constant pattern null handles missing input. The declaration patterns PremiumCustomer p, MemberCustomer m, GuestCustomer g test the runtime type and bind the typed reference in one step. The ordering puts PremiumCustomer (the most derived) above MemberCustomer (its base), so the premium arm wins for a Premium instance. The var other arm at the end gives a useful diagnostic for any future subtype someone forgets to wire in.

That progression, null → most derived → less derived → catch-all, is the standard skeleton of a type-pattern dispatch. With it in hand, switch expressions replace towers of if/else if.

Quiz

Type Patterns Quiz

10 quizzes