Using Pattern Matching with C# Switch Expression

Classification and decision logic are among the most common tasks in application code. Developers must routinely map input values to categories, dispatch behavior based on object types, and validate data against a set of business rules. Traditionally, these scenarios have been addressed using long if-else chains or switch statements that compare against constant values only.

Modern C# offers a more expressive alternative: pattern matching. Introduced in C# 7.0 and expanded in every major language release since, pattern matching allows developers to evaluate types, properties, relations, and logical conditions within a single, declarative construct.

The Limitations of Conditional Chains

Consider a typical classification method built from sequential conditional checks:

if (OPT_INTERNALCODES.Contains(code))
    return "FUTURES&OPTIONS";
else if (EQ_INTERNALCODES.Contains(code))
    return "EQUITY";
else if (FUND_INTERNALCODES.Contains(code))
    return "MUTUAL_FUND";
// ...the chain continues to grow
return string.Empty;

While functionally correct, this approach presents several maintainability concerns as the number of conditions increases:

  • Reduced readability. Each rule must be traced through nested braces.
  • Silent omissions. A missing branch may fall through to unintended behavior without any compiler feedback.
  • Structural fragility. Extending the chain risks introducing errors during modification.

Deeply nested if-else structures are precisely the kind of construct that pattern matching was designed to replace.

What Is Pattern Matching?

Pattern matching is a language feature that enables code to inspect data by matching it against structural descriptions (Or you can call patterns) rather than by comparing it against discrete constant values.

A pattern answers one of three questions:

  1. What type is this value?
  2. What shape (properties, positions, elements) does this value have?
  3. What condition must this value satisfy?

Pattern matching integrates with two primary constructs: the is expression and the switch construct (in both statement and expression forms).

-Pattern Matching with the is Expression

The simplest entry point is the is expression, which evaluates a pattern inline and, in the case of declaration patterns, binds the result to a new variable:

if (input is string s && s.Length > 0)
{
    Console.WriteLine($"Non-empty string: {s}");
}

This is particularly useful for type checks combined with lightweight extraction. However, when more than one branch is required, the switch construct becomes the clearer choice.

-The Possible Pattern

The following table summarizes the patterns supported by the language:

PatternPurposeExample
Constant patternMatches a specific constant valuecase 0
Type / declaration patternMatches runtime type and binds the valuecase string s
Var patternBinds the value to a new variable of inferred typecase var c when c.IsValid
Discard patternMatches anything; used as a fallback_
Property patternMatches on property values{ Extension: ".html" }
Positional patternMatches on deconstructed positionscase (int x, int y)
Relational patternMatches via numeric comparison>= 80
Logical patternsCombine or negate patterns (and, or, not)>= 60 and < 80
Parenthesized patternGroups patterns explicitlynot (0 or 1 or 2)
List pattern (C# 11)Matches sequences of elements[1, 2, ..]
Slice patternCaptures a portion of a sequencecase [_, .., 0]
Null patternMatches a null referencecase null

💡 Precedence matters. not binds tighter than an`, which binds tighter than or.
value is not 0 or 1 or 2 parses as (not 0) or 1 or 2 — almost always true, not what you likely meant.
Use parentheses explicitly: value is not (0 or 1 or 2).

Recursive patterns are compositions of several patterns nested together
for example, a property pattern whose sub patterns are themselves property patterns:
{ Location: { Latitude: var lat } }

-Relational and Logical Patterns in Practice

Relational patterns, introduced in C# 9.0, enable range-based classification without verbose Boolean logic

string classification = userScore switch
{
    < 0 or > 100      => "Invalid score",
    < 60              => "Needs Improvement",
    >= 60 and < 80    => "Satisfactory",
    >= 80 and <= 100  => "Excellent",
    _                 => "Unexpected value"
};

Note the use of the or pattern to express a disjunction — a capability unavailable in traditional if-else form without repeating the variable

List patterns, introduced in C# 11, extend this expressiveness to sequences:

string verdict = readings switch
{
    []              => "No data",
    [0, ..]         => "Starts with zero",
    [.., > 100]     => "Ends above threshold",
    [1, 2, .. var rest] => "Standard prefix",
    _               => "Unrecognized sequence"
};
-From Switch Statements to Switch Expressions

The original pattern-matching switch is the statement form. The following example demonstrates the var _ idiom combined with a when guard:

switch (internalCode)
{
    case var _ when INTERNAL_CODE_CONST.OPT_INTERNALCODES.Contains(internalCode):
        return "FUTURES&OPTIONS";
    case var _ when INTERNAL_CODE_CONST.EQ_INTERNALCODES.Contains(internalCode):
        return "EQUITY";
    default:
        return string.Empty;
}

Since C# 8.0, the same logic can be expressed more concisely as a switch expression:

return internalCode switch
{
    var c when INTERNAL_CODE_CONST.OPT_INTERNALCODES.Contains(c) => "FUTURES&OPTIONS",
    var c when INTERNAL_CODE_CONST.EQ_INTERNALCODES.Contains(c)  => "EQUITY",
    _                                                            => string.Empty,
};

One syntactic detail deserves attention: in a switch statement, a bare discard cannot be used as a case label; the discard must be part of a pattern such as case var _ or case object _. In a switch expression, the bare discard arm _ => is permitted because it is part of the expression grammar rather than a case label

- Compile-Time Warnings and Runtime Consequences

Pattern-matching constructs provide stronger guarantees about completeness than conditional chains:

  • A switch statement that fails to cover every possible input produces a compiler warning (CS8509), surfacing the omission at build time.
  • A switch expression that is not exhaustive also produces a warning; if such an expression is evaluated at runtime and no arm matches, the runtime throws a SwitchExpressionException.

This dual protection is significant: whereas an incomplete if-else chain may silently fall through, an incomplete pattern-matching switch either warns at compile time or fails loudly at runtime.

Advantages

📌 Improved Readability. Each classification rule is expressed as a discrete, self-contained clause. The complete mapping logic can be reviewed without navigating nested control flow, reducing the cognitive effort required for code review and maintenance.

📌 Compiler-Enforced Completeness. Omitted cases are surfaced through compiler warnings (and, for switch expressions, through SwitchExpressionException at runtime), ensuring that unhandled paths are identified before deployment.

📌 Easier Testing. Because each pattern arm is isolated, individual branches can be verified independently, simplifying unit test design and improving coverage confidence.

📌 Maintainability at Scale. Adding a new classification category requires only a single clause, minimizing the risk of introducing errors during modification.

📌 Better Separation of Concerns. Pattern-matching constructs focus logic on input-to-output transformation, keeping classification concerns cleanly separated from surrounding business logic.

📌 Language Maturity and Stability. Pattern matching has been part of C# since version 7.0 and has been refined in every subsequent release. With .NET 10 shipping with C# 14 as the default language version, the feature is fully supported and extensively validated in production.

The following guidelines are recommended when adopting pattern matching in production code:

📌 Use record types/DTO for clean, immutable data objects that participate in matching logic. you can see my code example below

  • Record
public record InvestmentType(string InternalCode, string Exchange, string Category);
  • switch expression example:
public static string GetAssetClass(InvestmentType pInvestmentType) => pInvestmentType switch
{
    null => throw new ArgumentNullException(nameof(pInvestmentType)),   // ต้องมาก่อน
    { Category: "OPT" } => "FUTURES&OPTIONS",
    { Category: "EQ", Exchange: "SET" } => "EQUITY",
    { InternalCode: var code } when INTERNAL_CODE_CONST.FUND_INTERNALCODES.Contains(code)
        => "MUTUAL_FUND",
    _ => "UNCLASSIFIED"
};

📌 Prefer switch expressions over if-else trees when the logic is naturally expression-oriented.

📌 Use not patterns and the discard pattern (_) to handle edge cases and irrelevant matches explicitly.

📌 Always include a terminal arm (default case or discard) so that unknown inputs are handled deliberately.

📌 Extract complex patterns into helper methods to preserve the readability of the switch expression.

📌 Treat compiler exhaustiveness warnings as errors that must be resolved, not suppressed.

Performance Considerations

A common assumption is that switch with pattern matching is "free" compared to if-else — this is only true for a specific shape of switch.

Constant-only switches compile to a jump table (or a hash-based dispatch for strings). The runtime cost is close to O(1) regardless of how many cases exist.

string result = code switch
{
    "OPT" => "FUTURES&OPTIONS",
    "EQ"  => "EQUITY",
    "FUND" => "MUTUAL_FUND",
    _ => string.Empty,
};

The moment a when guard enters the picture, that optimization disappears. The compiler falls back to sequential evaluation — functionally identical to an if-else chain, arm by arm, top to bottom:

return internalCode switch
{
    var c when INTERNAL_CODE_CONST.OPT_INTERNALCODES.Contains(c) => "FUTURES&OPTIONS",
    var c when INTERNAL_CODE_CONST.EQ_INTERNALCODES.Contains(c)  => "EQUITY",
    _ => string.Empty,
};

This isn't a flaw — it's the honest trade-off. What switch/when buys you is readability and compiler-checked structure, not raw speed. If the hot path is doing set-membership checks like the example above, the switch itself is not the bottleneck — the repeated Contains calls on each HashSet/List are. Two things worth checking before optimizing further:

  • If OPT_INTERNALCODES etc. are List<string>, Contains is O(n) per arm, making the whole switch O(n×m) in the worst case. Backing them with HashSet<string> (or FrozenSet<string> on .NET 8+) brings each check down to O(1).
  • For a pure code → category mapping with no branching logic, a FrozenDictionary<string, string> is often a better fit than a switch entirely — it's O(1), and the mapping can be built from configuration instead of being baked into compiled code:
private static readonly FrozenDictionary<string, string> AssetClassMap =
    new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
    {
        ["OPT"] = "FUTURES&OPTIONS",
        ["EQ"]  = "EQUITY",
        ["FUND"] = "MUTUAL_FUND",
    }.ToFrozenDictionary();

Reach for switch/when when the logic is genuinely conditional (types, shapes, ranges, combinations of properties) — reach for a dictionary when it's really just a lookup wearing a switch's clothes.

Use Cases

The switch + when construct is particularly well suited to scenarios in which:

  • A value must be classified based on membership across multiple predefined sets.
  • The number of classification rules is expected to increase over time.
  • Individual rules must remain independently comprehensible for auditing or maintenance purposes.
  • Input may arrive as heterogeneous types or shapes that must be dispatched differently.

Conclusion

Switch pattern matching is more than a syntactic convenience — it is a paradigm for writing safer, more expressive classification logic. With a rich vocabulary of patterns, compiler-enforced exhaustiveness, and continued investment from the language team through C# 11 and beyond, it has become the idiomatic choice for decision logic in modern C#. For codebases that still rely on extended if-else chains for such purposes, adopting the switch construct with when clauses represents a meaningful improvement in clarity, correctness, and long-term maintainability.

And the bonus: For a single condition, a ternary expression remains the good choice:

return INTERNAL_CODE_CONST.OPT_INTERNALCODES.Contains(code)
    ? "FUTURES&OPTIONS"
    : string.Empty;

Reference