📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What kinds of pattern matching does C# 14 support? Explain type, constant, relational, logical, property (including extended property), positional, var, discard and list patterns, how the compiler checks exhaustiveness and subsumption, and when pattern matching is better or worse than polymorphism.

The full C# pattern language: every pattern kind with its version, how patterns compose, compiler exhaustiveness and subsumption checks, performance of the generated decision tree, and design trade-offs versus virtual methods.

Mid-Level Theory Advanced csharp design list-patterns pattern-matching switch-expressions

🎯 Key points

  • Type and declaration patterns (`o is Order order`), constant patterns (`is null`, `is 0`)
  • Relational (`< 0`, `>= 100`) and logical (`and`, `or`, `not`) patterns, C# 9
  • Property patterns `{ Status: OrderStatus.Paid }` and extended property patterns `{ Customer.Address.State: "NSW" }` (C# 10)
  • Positional patterns use Deconstruct; `var` and discard `_` patterns
  • List patterns `[first, .., last]` and slice patterns (C# 11) on arrays, lists and spans
  • `is not null` is the idiomatic null check and ignores overloaded == operators
  • The compiler builds a decision tree, warns on non-exhaustive switch expressions (CS8509) and errors on subsumed cases
  • Pattern matching suits closed sets of data shapes; polymorphism suits open sets of behaviour

⏱ 30-second answer

C# patterns now cover most data checks. There are type, constant and null patterns, relational ones like >= 100, logical and, or and not combinators, and property patterns, including extended ones like { Customer.Address.State: "NSW" }. There are also positional patterns that use Deconstruct, var and discard patterns, and list patterns like [first, .., last]. The compiler turns a switch into an efficient decision tree, warns when it isn't exhaustive, and errors on unreachable cases. I use patterns for closed sets of data shapes and polymorphism when new types keep being added.

🗣 Full answer (senior)

How I would answer this in an interview

Pattern matching lets you test a value's shape and extract parts of it in one expression, in is, switch statements and switch expressions. C# 14 supports a rich set of patterns that compose with and, or, not. I'd walk through each kind, how the compiler checks exhaustiveness and subsumption, and when pattern matching beats polymorphism (and vice versa).

1. The patterns

Pattern Example Matches
Type / declaration shape is Circle c Runtime type is Circle (and binds c)
Constant status is OrderStatus.Paid, x is null, s is "" Equal to a constant
Relational (C# 9) age is >= 18, temp is < 0m Comparison with a constant
Logical (C# 9) x is > 0 and < 100, c is 'a' or 'e', obj is not null Combine patterns
Property order is { Status: OrderStatus.Paid, Total: > 1000m } Properties match subpatterns (implies non-null)
Extended property (C# 10) order is { Customer.Address.State: "NSW" } Nested properties with null checks
Positional point is (0, 0), (a, b) is (> 0, > 0) Uses Deconstruct/tuples
var GetValue() is var v Always matches, binds a variable (useful in guards)
Discard _ (in switch expressions) Anything
List (C# 11) args is ["deploy", var env, ..], bytes is [0xFF, 0xD8, ..] Sequence length and elements
Parenthesised x is (> 0 and < 10) or 100 Grouping

Type patterns can test interfaces and generic types; relational patterns work with numeric types, char, enums (via underlying values) and other constants.

2. Using them

decimal Shipping(Order order) => order switch
{
    { Total: >= 150m }                                   => 0m,
    { Customer.Address.State: "NSW" or "VIC" or "ACT" }  => 9.95m,
    { Customer.Address.State: "WA" or "NT" }             => 19.95m,
    { Lines: [] }                                        => throw new InvalidOperationException("Empty order"),
    _                                                    => 14.95m
};

if (message is OrderShipped { TrackingNumber: { Length: > 0 } tracking })
    Notify(tracking);

3. Exhaustiveness and subsumption

The compiler builds a decision DAG from all arms:

  • Exhaustiveness: if some input could match no arm, a switch expression gets warning CS8509 ("not exhaustive") and, at run time, an unmatched value throws SwitchExpressionException. For enums, the compiler requires a discard because any integer value is possible; for bool, both values must be covered; for nullable inputs, null must be handled.
  • Subsumption: an arm that can never be reached because earlier arms cover all its cases is an error (CS8510) in switch expressions and switch statements — e.g. int before int n when n > 0, or > 0 before > 10.
  • when guards are opaque to exhaustiveness analysis (the compiler can't reason about arbitrary boolean expressions).

4. Pattern matching vs polymorphism

The classic expression problem:

Polymorphism (virtual/interface) Pattern matching (switch over types)
Add a new type Easy: new class implements the method Hard: update every switch
Add a new operation Hard: add a method to every class Easy: write a new function with a switch
Behaviour location Spread across types Centralised in one place
Best for Open hierarchies, plug-ins, extensibility Closed sets: results, messages, ASTs, state machines, DTO mapping

Pattern matching is better when the set of types is closed (e.g. PaymentResult.Approved/Declined/Failed), when the operation belongs to a different layer (mapping domain results to HTTP responses shouldn't live in domain classes), when conditions combine several properties (shipping rules), and for parsing/inspecting data.

Polymorphism is better when new types are added by different teams or plug-ins, when behaviour is intrinsic to the type, and when the same switch would be duplicated in many places.

5. A production example

A rules engine for shipping fees had nested if/else blocks across 200 lines checking states, totals, weights and member tiers; bugs appeared whenever a rule was added. We rewrote it as a switch expression with property and relational patterns ordered from most to least specific, with unit tests per arm. The compiler's subsumption errors immediately caught two rules that could never fire in the old code, and adding a new state became a one-line arm.

🔍 What the interviewer is checking

Checks breadth of modern C# and design judgement.

Green flags: names most pattern kinds with rough versions; extended property and list patterns; exhaustiveness and subsumption; is null; patterns versus polymorphism trade-off.

Red flags: only knows is Type t; ignores exhaustiveness; uses type switches everywhere.

📚 Learn in depth

1. How patterns compile

The compiler lowers patterns into efficient code:

  • Type patterns → isinst + null check (one operation; the bound variable is the cast result).
  • Constant patterns → equality comparisons (== for primitives, object.Equals for some cases; is null → reference check that ignores operator overloads).
  • Relational patterns → comparison instructions.
  • Property patterns → null check + property reads (each property read once even if used by several arms, thanks to the decision DAG).
  • List patterns → Length/Count check + indexer access ([^1] uses Index support) + Slice for captured slices.
  • A whole switch is turned into a decision DAG that shares tests between arms and may use jump tables for dense constants.

2. Exhaustiveness algorithm

The compiler considers the input's domain (all values of the type, plus null for reference/nullable types) and checks whether the union of arms (without when clauses) covers it. For numeric relational patterns it reasons about ranges; for enums, named members don't make it exhaustive (other values are possible); for closed sets of types it can't know a hierarchy is closed, so a discard is needed.

3. Patterns and nullability

Property patterns { } imply not-null (x is { } nonNull is a common non-null test). Nullable flow analysis understands patterns: after if (o is { Customer: { } c }), c is non-null. Type patterns never match null.

4. List patterns details

List patterns require countable (Length/Count) and indexable (this[int] or this[Index]) types; slice patterns .. require Slice/range indexer support to capture (.. var rest). On arrays and strings, capturing a slice allocates a new array/string; on spans it's free.

5. Combining with when

order switch
{
    { Total: > 1000m } when customer.IsVip => 0m,
    ...
}

when is evaluated only if the pattern matched; use it for conditions not expressible as patterns (method calls, cross-variable comparisons). Remember it defeats exhaustiveness analysis for that arm.

6. Performance

Pattern matching is generally as fast as hand-written if chains (often faster, due to shared tests). Type switches over many types are linear isinst checks; virtual dispatch can be faster for very large hierarchies, but for typical result types the difference is negligible.

Try it yourself

  1. Write a switch expression over an enum without a discard and call it with (OrderStatus)99.
  2. Order arms incorrectly to trigger CS8510.
  3. Use list patterns to parse command-line arguments.
  4. Implement the same feature with virtual methods and with pattern matching; add a new type and a new operation to each.

💻 Code

using System.Diagnostics;

namespace Advanced.Patterns;

public enum CustomerType { Consumer, Business }
public sealed record Address(string State, string Country);
public sealed record Customer(CustomerType Type, Address Address, string? Abn);
public sealed record Order(Customer Customer, decimal Total, string Category, IReadOnlyList<string> Skus, bool IsCancelled);

public static class Gst
{
    public static decimal For(Order order) => order switch
    {
        { IsCancelled: true }                                        => 0m,
        { Customer.Address.Country: not "AU" }                       => 0m,       // export
        { Skus: [] }                                                 => 0m,       // list pattern
        { Category: "BasicFood" or "Medical" }                       => 0m,       // GST-free categories (simplified)
        { Customer: { Type: CustomerType.Business, Abn: null } }     => throw new InvalidOperationException("Business customer without ABN"),
        { Total: > 0m and var total }                                => decimal.Round(total / 11m, 2), // GST-inclusive price
        { Total: <= 0m }                                             => 0m,
    };

    public static string Describe(object? value) => value switch
    {
        null => "nothing",
        int n when n < 0 => "negative int",
        int and (0 or 1) => "bit",
        int => "int",
        string { Length: 0 } => "empty string",
        string s => $"string of {s.Length}",
        int[] and [var first, .., var last] => $"array {first}..{last}", // type pattern combined with a list pattern
        _ => value.GetType().Name,
    };

    public static string Size(CustomerType type) => type switch
    {
        CustomerType.Consumer => "B2C",
        CustomerType.Business => "B2B",
        _ => throw new UnreachableException($"Unknown customer type {type}"), // enums can hold unnamed values
    };
}

public static class Program
{
    public static void Main()
    {
        var nsw = new Address("NSW", "AU");
        var order = new Order(new Customer(CustomerType.Consumer, nsw, null), 110m, "Hardware", ["H1"], false);
        Console.WriteLine(Gst.For(order));                        // 10.00
        Console.WriteLine(Gst.Describe(new[] { 3, 5, 9 }));       // array 3..9
        Console.WriteLine(Gst.Describe(1));                       // bit
        Console.WriteLine(Gst.Size(CustomerType.Business));

        if (order is { Customer.Address: { State: "NSW" or "VIC" } address }) Console.WriteLine($"East coast: {address.State}");
    }
}

⚠ Common mistakes

1. Forgetting the discard arm

What happens: switch expressions over enums or open hierarchies without _. Why it is wrong: SwitchExpressionException at run time for unexpected values. Do instead: add _ => throw new UnreachableException() (or a sensible default) and treat CS8509 as an error.

2. Ordering arms from general to specific

What happens: broad patterns before specific ones. Why it is wrong: specific arms are unreachable (compile error) or wrongly shadowed with when guards. Do instead: order from most specific to most general.

3. Type switches duplicated everywhere

What happens: the same switch over subtypes in many services. Why it is wrong: adding a type means editing many places. Do instead: use polymorphism for open behaviour, or centralise the switch.

4. Overly complex patterns

What happens: deeply nested property and list patterns in one arm. Why it is wrong: hard to read and debug. Do instead: extract helper properties or methods, or split into multiple arms.

5. Using when for everything

What happens: case Order o when o.Total > 1000 && o.Status == Paid. Why it is wrong: loses exhaustiveness analysis and readability. Do instead: use property and relational patterns.

6. not precedence errors

What happens: x is not null or "" expected to mean "not null and not empty". Why it is wrong: not binds tighter than or. Do instead: x is not (null or "").

7. Slices allocating unexpectedly

What happens: list patterns capturing slices of arrays in hot paths. Why it is wrong: allocates new arrays. Do instead: match on spans or avoid capturing slices.

8. Putting presentation logic into domain types to avoid switches

What happens: domain results get ToHttpResult() methods. Why it is wrong: couples layers. Do instead: pattern-match in the presentation layer.

🏛 Senior insight: trade-offs

Modern C# patterns let you write business rules as readable decision tables with compiler-checked exhaustiveness. Seniors know the full pattern set, order cases correctly, treat CS8509 as an error, and make a conscious choice between patterns (closed data, many operations) and polymorphism (open types, stable operations). I often combine them: polymorphic types for behaviour, pattern matching at the edges for parsing, mapping and rule tables.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate mentions is and type patterns. Why: Checks the operator and pattern basics from C# fundamentals.
    How to answer

    An expression produces a value, like a + b or order.Total > 100; a statement does something, like if, foreach, return or a declaration. C# has become more expression-oriented: a switch expression returns a value, cannot fall through and warns if it is not exhaustive, and patterns (type, relational, property, list, and/or/not) let you test shape and values in one place. Expression-bodied members use => for one-line members, and throw expressions let ?? and ?: throw. I use expressions to compute values without mutation and statements when there are steps or side effects.

  2. When: Candidate writes a switch with patterns. Why: Switch expressions are where most patterns live in production code.
    How to answer

    A switch expression returns a value: status switch { Paid => "Paid", _ => "Other" }. Each arm is a pattern, an optional when guard, and an expression. There's no fall-through or break, and the first matching arm wins. The result type comes from the arms or from the target. The compiler warns with CS8509 if it isn't exhaustive, and an unmatched value throws SwitchExpressionException. Switching on a tuple like (state, trigger) gives very readable state machines. When an arm needs several statements or side effects, a classic switch statement or polymorphism is clearer.

  3. When: Candidate mentions positional patterns. Why: Positional patterns depend on Deconstruct.
    How to answer

    Deconstruction unpacks a value into separate variables, like var (id, name) = customer;. Tuples support it built in, positional records generate a Deconstruct method, and any other type works if it has a Deconstruct(out ..., out ...) method, which can be an extension, so you can add it to types you don't own. You can skip parts with _, deconstruct dictionary entries in foreach, and since C# 10 mix new and existing variables. Positional patterns like p is (0, var y) use the same Deconstruct method.

🛠 Real-world problems: how you handle them

Nested GST rules hid a missing case
Situation
An e-commerce platform's GST calculation was a 120-line nested if/else over customer type, country, category and ABN status.
Signals
Occasional wrong GST on invoices for overseas business customers, found by the finance team during BAS preparation.
Investigation
Wrote table-driven unit tests from the ATO rules the business had agreed, found two combinations that fell through to the default branch.
Resolution
Rewrote the logic as a single switch expression with property, relational and logical patterns; the CS8509 warning flagged the uncovered combination, which we added explicitly.
Prevention
Treated CS8509 as an error in the project, kept the rule table tests, and required finance sign-off on rule changes.

How to tell it (STAR)

**S:** Finance found GST errors on some overseas business invoices. **T:** Fix the calculation and make the rules auditable. **A:** I rewrote the nested conditions as one pattern-matching switch expression with table-driven tests, and the compiler's exhaustiveness warning exposed the missing case. **R:** The errors stopped, and finance can now review the rules as a readable table in [format].

↩ Usually asked after

More in Advanced Language Features

Last updated 30 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me