Pattern matching turned C#'s switch from a blunt jump table into a tool for expressing type tests, structural decomposition and conditional logic in a single expression. Interviewers use it to probe further than syntax recall: do you know when a switch expression is genuinely safer than an if chain, can you model a closed set of alternatives so the compiler catches a missing case instead of a code reviewer, and do you understand when matching on shape is clearer than dispatching through an interface. The topic also opens onto functional-style C#: immutability, pure functions, and Result/Option types that replace exceptions for expected failures. At the senior and staff level, expect questions to move quickly from "can you write this" to "when does this technique make the code worse," because that judgment, not the syntax, is what the interview is actually measuring.

Q1 What kinds of patterns does C# support, and how do they compose inside a switch expression?#

Short answer: C# has type, declaration, constant, relational, logical, property, positional, var, discard, and list/slice patterns. They compose freely, so a single arm can nest a type check, a property read and a relational comparison, which is what makes pattern matching more expressive than a chain of if statements.

Each pattern answers a different question about a value. Constant and relational patterns test scalars (x is 404, age is >= 18). Logical patterns combine other patterns with and, or and not, with not binding tightest and or loosest, so mixed expressions need parentheses to read correctly. Type and declaration patterns test the runtime type and optionally introduce a variable. Property patterns read members recursively, including nested paths since C# 10 ({ Customer.Address.Country: "US" }). Positional patterns call Deconstruct and match tuple-like shapes. List patterns, from C# 11, match sequence length and elements, including a single .. slice.

C#
static string Classify(object value) => value switch
{
    null => "empty",
    int n and (< 0 or > 100) => "out of range",
    int n => $"integer {n}",
    string { Length: 0 } => "empty string",
    string s and not "" => $"text of length {s.Length}",
    (int x, int y) point => $"point ({x}, {y})",
    [var first, .., var last] items => $"sequence from {first} to {last}",
    _ => "unhandled",
};

Because patterns nest, you rarely need a helper method just to combine two checks; the arm itself is the check.

What interviewers look for: fluency across the full pattern vocabulary, not just is Type t, and awareness that is null and is not null bypass a type's overloaded == operator, which makes them the safer null check in code that might override equality.

Common mistakes: forgetting operator precedence in mixed logical patterns, and using positional patterns on types that do not have a meaningful Deconstruct, which produces a working but unreadable match.

Q2 How does the compiler decide whether a switch expression is exhaustive, and what happens when it gets it wrong?#

Short answer: The compiler performs static reachability analysis over the declared patterns and the static type of the input. If it cannot prove every possible value is handled, it emits warning CS8509; if an arm can never be reached because earlier arms already cover it, that is the error CS8510. At run time, an unmatched value throws SwitchExpressionException.

This is a warning, not a compile error, which surprises people who expect exhaustiveness checking to be as strict as, say, Rust's. The compiler reasons only from types and literal patterns it can see: a switch over a bool with both true and false handled is exhaustive; a switch over an open class hierarchy almost never is, because any assembly could add another subtype. For enums specifically, CS8524 fires when every named member is handled but an out-of-range value such as (DayOfWeek)99 is not, since enums are backed by an integral type with no real range restriction.

C#
public enum ShipmentStatus { Pending, InTransit, Delivered, Returned }

// No discard arm: adding a new enum member produces CS8509 here, at compile time.
static string Describe(ShipmentStatus status) => status switch
{
    ShipmentStatus.Pending => "waiting for pickup",
    ShipmentStatus.InTransit => "on the way",
    ShipmentStatus.Delivered => "delivered",
    ShipmentStatus.Returned => "returned to sender",
};

A common senior-level judgment call is whether to add a _ => throw new UnreachableException() discard arm. Adding one silences CS8509 forever, including for cases added a year later; omitting it turns "a teammate added a case" into a build break today instead of a runtime exception in production.

What interviewers look for: knowing that exhaustiveness is a warning derived from static analysis, not a runtime guarantee, and having an opinion on discard arms rather than reflexively adding _ => default everywhere.

Follow-up questions:

  • How would you make a missing case a build failure instead of a warning?
  • What is the difference in exhaustiveness rules between a switch statement and a switch expression?

Q3 When should you reach for pattern matching instead of polymorphism to vary behavior by type?#

Short answer: Use polymorphism when the set of types is open and each type owns its behavior; use pattern matching when the set of cases is closed, the logic is a pure transformation better read in one place, or the types are already fixed (framework types, DTOs) and you cannot add a virtual method to them.

This is the object-oriented "visitor problem" restated: adding a new type is easy with virtual dispatch and hard with a switch, because every switch over the hierarchy needs a new arm; adding a new operation is the reverse, easy with a switch and hard with virtual methods, because every type needs a new override. Domain entities that gain new subtypes rarely (order states, payment outcomes, parser results) are excellent switch candidates, especially once record hierarchies can be closed for compiler-checked exhaustiveness, as covered in the records and pattern matching guide. Types that gain new subtypes often, or whose behavior should be encapsulated with their data (a plugin system, a UI component tree), fit virtual dispatch better.

C#
// Pattern matching: good fit, the case set is closed and stable, logic is a pure mapping.
static decimal ShippingCost(OrderState state) => state switch
{
    OrderState.Draft => 0m,
    OrderState.Placed { Total: >= 50m } => 0m,
    OrderState.Placed placed => 4.99m,
    OrderState.Cancelled => 0m,
    _ => throw new UnreachableException(),
};

There is also a testability angle: a switch expression is a pure function you can unit test with a table of inputs and outputs, while behavior spread across virtual overrides requires instantiating each subtype.

What interviewers look for: a decision framework (who adds new cases, is the logic behavior or data transformation, is the type under your control) rather than a blanket preference for one style.

Common mistakes: rewriting a stable, closed enum-like hierarchy as an inheritance tree "for OOP purity," which adds files and indirection without adding flexibility that anyone needs.

Q4 How do you model a discriminated union in C# today, and what changes with the union feature coming in C# 15?#

Short answer: Today you model a discriminated union with an abstract record and sealed nested records, matched with switch, accepting that the hierarchy is open so you need a discard arm. C# 15, which reached release-candidate status in September 2026 alongside .NET 11 and is expected to reach general availability that November, adds a closed modifier for compiler-verified exhaustive hierarchies and a union keyword for composing unrelated types into a genuine closed union.

The abstract-record pattern is the workhorse on .NET 8, 9 and 10: define an abstract base, seal each case as a nested record, and match with a switch that ends in a discard arm that throws, so an unhandled case fails loudly instead of silently returning a default.

C#
public abstract record DownloadResult
{
    public sealed record Success(byte[] Content, string ContentType) : DownloadResult;
    public sealed record NotFound(string Path) : DownloadResult;
    public sealed record Failed(string Reason) : DownloadResult;
}

static string Describe(DownloadResult result) => result switch
{
    DownloadResult.Success s => $"{s.Content.Length} bytes of {s.ContentType}",
    DownloadResult.NotFound n => $"missing: {n.Path}",
    DownloadResult.Failed f => $"failed: {f.Reason}",
    _ => throw new UnreachableException(), // required: the hierarchy is open pre-C# 15
};

With C# 15's closed modifier, marking DownloadResult closed restricts direct subtypes to the declaring assembly, so a switch that handles Success, NotFound and Failed is exhaustive with no discard arm and no CS8509. The separate union keyword goes further: it composes types that need not share any base class at all into one closed set, which suits cases like a lookup result that is either a domain entity, a not-found marker, or a validation error with no natural common parent. Before C# 15 ships broadly, this is the honest answer: C# has never had a first-class discriminated union, and the abstract-record idiom, or a third-party library, has been the substitute.

What interviewers look for: an accurate, dated answer about the language's current state rather than a confident claim that "C# has unions" or "C# will never have unions" — both are wrong at different points in time, and this question rewards someone who tracks the language rather than one who learned it once.

Common mistakes: claiming records give you exhaustiveness automatically; without closed, they do not, and forgetting the discard arm is a real production bug, not a style nitpick.

Q5 How do positional patterns interact with records, and what actually executes at run time?#

Short answer: A positional pattern calls the target's Deconstruct method and matches its out parameters against nested patterns; for a positional record, the compiler generates that Deconstruct for you, so order is Order(var customer, var total) is exactly as fast as calling the generated method directly.

This matters because positional patterns are not reflection or dynamic dispatch; they compile to an ordinary method call followed by pattern tests on the results, so there is no meaningful performance tax versus hand-written property access. Positional patterns also combine with property patterns in one expression, letting you decompose some members and inspect others by name.

C#
public sealed record Address(string City, string Country);
public sealed record Customer(string Name, Address Address);
public sealed record Order(Customer Customer, decimal Total);

static decimal Discount(Order order) => order switch
{
    // Positional pattern on Address via Customer.Address: (City, Country) calls Deconstruct.
    { Customer: { Address: ("Oslo", _) }, Total: >= 100m } => 0.10m,
    { Customer.Address.Country: "US" } => 0.05m,
    _ => 0m,
};

Any type can opt into positional pattern support by writing its own Deconstruct, including types you do not own, through an extension Deconstruct method — extension methods participate in pattern matching the same way they participate in ordinary method resolution. This is a useful escape hatch for matching on library or framework types you cannot modify.

What interviewers look for: understanding that positional patterns are sugar over a real method call, which explains both their performance and how to extend matching to arbitrary types with a hand-written Deconstruct.

Follow-up questions:

  • How would you add positional pattern support to a third-party type you cannot change?
  • What happens if Deconstruct has side effects — is that a good idea?

Q6 What is the difference between property patterns and list patterns, and where do list patterns fall short?#

Short answer: Property patterns match named members by value or nested pattern; list patterns, from C# 11, match a sequence's length and its elements by position, with a single .. slice standing in for "the rest." List patterns need the type to be countable (a Length or Count member) and indexable, so they work on arrays, List<T>, spans and strings, but not on a plain IEnumerable<T>.

That last restriction trips people up constantly: IEnumerable<T> has no indexer and no guaranteed length, so items is [var first, ..] does not compile against it. You either materialize the sequence first (ToArray(), ToList()) or accept that list patterns are for already-realized collections, not for streaming LINQ pipelines. A slice pattern that captures a value, .. var rest, additionally needs range/slice support, which arrays, spans, List<T> and strings all provide.

C#
static string Summarize(int[] scores) => scores switch
{
    [] => "no scores",
    [var only] => $"single score {only}",
    [var first, .., var last] when last > first => "improving",
    [.., var last] => $"ended at {last}",
};

// Property pattern: matches named members regardless of position or count.
static bool IsPriorityShipment(Order order) => order is { Total: >= 500m, Customer.IsVip: true };

List patterns are best for parsing command-line arguments, matching small fixed-shape payloads and validating array edges (a leading header byte, a trailing checksum). For general filtering, ordinary LINQ still reads better; the value of a list pattern is testing shape and content together in one expression the compiler can reason about.

What interviewers look for: knowing the indexable/countable constraint precisely, since it is the detail that turns "works in my quick test" into "doesn't compile against my actual interface type" in real code.

Common mistakes: trying to list-pattern-match an IQueryable<T> or a raw IEnumerable<T> and being surprised it will not compile.

Q7 How would you design a Result or Option type in C#, and when is it better than exceptions or nullable types?#

Short answer: A minimal Result<T, TError> is a readonly struct holding either a value or an error plus a discriminator, exposed through pattern-matchable properties or a Match method; it is worth the ceremony when failure is an expected, frequent outcome that callers must handle explicitly, not an exceptional one.

Exceptions communicate "something broke the contract," and unwinding the stack has real cost on the failure path; they are the right tool for programmer errors and true exceptional conditions. Result/Option types communicate "this operation has more than one legitimate outcome," such as validation failure, a not-found lookup, or a parse error, and they make the failure path visible in the method signature, which the compiler and the caller can both see. A nullable return type is a lightweight Option<T> for the single "absent" case but cannot carry error detail, which is exactly what Result<T, TError> adds.

C#
public readonly struct Result<TValue, TError>
{
    private readonly TValue? _value;
    private readonly TError? _error;
    public bool IsSuccess { get; }

    private Result(TValue? value, TError? error, bool isSuccess) =>
        (_value, _error, IsSuccess) = (value, error, isSuccess);

    public static Result<TValue, TError> Success(TValue value) => new(value, default, true);
    public static Result<TValue, TError> Failure(TError error) => new(default, error, false);

    public TResult Match<TResult>(Func<TValue, TResult> onSuccess, Func<TError, TResult> onFailure) =>
        IsSuccess ? onSuccess(_value!) : onFailure(_error!);
}

static Result<int, string> ParseAge(string input) =>
    int.TryParse(input, out var age) && age is >= 0 and < 150
        ? Result<int, string>.Success(age)
        : Result<int, string>.Failure($"'{input}' is not a valid age");

In practice, most teams do not hand-roll this: community libraries such as OneOf and language-ext provide well-tested union and result types, and ASP.NET Core's Results<TResult1, TResult2, ...> return type for Minimal APIs solves the same "explicit set of outcomes" problem for endpoint handlers specifically.

What interviewers look for: a clear line between "expected outcome, model it as data" and "broken invariant, throw," and awareness that a Result type is only valuable if callers are actually forced to handle both branches, for example through Match rather than an unchecked .Value property.

Common mistakes: building a Result type and then adding a .Value getter that throws on failure, which quietly reintroduces exceptions for control flow and defeats the entire point.

Q8 What makes a function "pure" in C#, and how far can you realistically push immutability in production code?#

Short answer: A pure function's output depends only on its inputs, and it has no observable side effects — no mutation of shared state, no I/O, no reliance on ambient state like DateTime.Now. In practice, push purity to the core domain logic and keep I/O, logging and clock reads at the edges, rather than aiming for a purely functional codebase.

C# is not a functional language by default: mutable fields, ambient statics and implicit exceptions are all available and idiomatic. What you can control is design: model domain state with immutable records, pass dependencies like TimeProvider explicitly instead of calling DateTime.UtcNow inline, and write the decision logic (pricing rules, state transitions, validation) as functions from data to data. This "functional core, imperative shell" split is what makes domain logic trivially unit-testable: no mocks, just inputs and assertions on outputs.

C#
// Pure: same input always produces the same output, no side effects.
static decimal ApplyDiscount(decimal total, CustomerTier tier) => tier switch
{
    CustomerTier.Gold => total * 0.9m,
    CustomerTier.Silver => total * 0.95m,
    _ => total,
};

// Impure: depends on ambient clock state and mutates a field as a side effect.
decimal ApplyLoyaltyBonus(Order order)
{
    if (DateTime.Now.DayOfWeek == DayOfWeek.Friday) order.Total *= 0.95m; // hidden dependency
    return order.Total;
}

Full immutability has a real cost: deeply immutable graphs mean every small change allocates a new object, and collections nested in records need explicit copying, as the with-expression pitfalls in the records and pattern matching guide show. Senior engineers apply purity selectively, where it buys testability and reasoning power, not as a dogma applied uniformly to an entire codebase including hot loops and mutable UI state.

What interviewers look for: a practical stance — where purity earns its cost and where it does not — rather than a textbook definition recited without a production trade-off attached.

Q9 Is heavy use of type-testing pattern matching a code smell? What are the performance implications versus virtual dispatch?#

Short answer: A single, well-placed switch over a closed set of types is not a smell; the smell is many scattered if (x is Foo) chains duplicated across the codebase, which is exactly the problem virtual dispatch or the visitor pattern was invented to solve. Performance-wise, a switch on type patterns is a sequence of isinst checks, roughly comparable to a chain of is checks, while a virtual call is a single indirect jump through the vtable; the difference rarely matters outside of tight loops.

The design smell is duplication, not the presence of a switch. If the same type-dispatch logic reappears in five files, each new subtype means five error-prone edits, and that is a real argument for centralizing behavior on the type itself (polymorphism) or centralizing the switch in one function that every caller uses. A single switch expression that is the definition of the operation, called from one place, is not duplication; it is the visitor pattern implemented with less ceremony than a Visit method per type.

C#
// One switch, one definition of the operation: not a smell.
static decimal Area(Shape shape) => shape switch
{
    Circle c => Math.PI * c.Radius * c.Radius,
    Rectangle r => r.Width * r.Height,
    Triangle t => 0.5 * t.Base * t.Height,
    _ => throw new ArgumentOutOfRangeException(nameof(shape)),
};

On performance, the type patterns above compile to isinst/is tests evaluated top to bottom, so a long arm list with the common case last is measurably slower than one with the common case first; order arms by expected frequency in hot paths. Virtual dispatch avoids that ordering concern entirely because it always jumps straight to the right implementation, which is the real (small) performance argument for polymorphism in a tight loop, separate from the design argument.

What interviewers look for: separating the design question (does this duplicate logic) from the performance question (isinst chain versus vtable jump), since candidates often conflate the two and answer with only one.

Common mistakes: "optimizing" a rarely-called switch by converting it to polymorphism for performance reasons that do not exist at that call frequency.

Q10 How do when guards interact with pattern ordering and exhaustiveness, and what bugs can they hide?#

Short answer: A when guard adds an arbitrary boolean condition to an already-matched pattern; because the compiler cannot evaluate a when clause statically, an arm with a guard never counts toward proving exhaustiveness, which is precisely warning CS8846.

Guards are evaluated only after the pattern itself matches, and only in source order, so a guarded arm placed above a more general unguarded one can silently absorb cases the guard was never meant to catch if the guard condition is looser than intended. The bigger risk is that because the compiler treats every guarded arm as "maybe doesn't cover this," a switch that is logically exhaustive (every guard condition really is covered when you reason about the domain) still produces CS8846 and still needs a discard arm, which some teams wrongly treat as "the switch is missing a case" and paper over with a wrong fallback value instead of a throw.

C#
static string Priority(ShipmentStatus status, TimeSpan waited) => (status, waited) switch
{
    (ShipmentStatus.Pending, var age) when age > TimeSpan.FromDays(2) => "escalate",
    (ShipmentStatus.Pending, _) => "normal",
    (ShipmentStatus.InTransit, var age) when age > TimeSpan.FromDays(5) => "escalate",
    (ShipmentStatus.InTransit, _) => "normal",
    _ => "none", // required: CS8846, even though every (status, TimeSpan) pair is reachable above
};

A subtler bug: two guarded arms with the same pattern but conditions that are not actually exhaustive between them (when age > 2 days and when age < 2 days, both missing == 2 days) leave a silent gap that falls through to whatever arm comes next, often the wrong one. Review guard conditions as a set for coverage, the same way you would review if/else if chains.

What interviewers look for: knowing that guards opt an arm out of exhaustiveness analysis entirely, and treating that as a reason to double-check guard coverage by hand rather than trusting the compiler.

Follow-up questions:

  • Why does the compiler warn on a switch you believe is logically complete once guards are added?
  • How would you test that a set of guard conditions has no gaps?

Quick-Fire Round#

QuestionAnswer
Does x is null call a custom == operator?No, pattern matching against null never invokes overloaded operators.
Are switch statements required to be exhaustive?No, only switch expressions produce exhaustiveness warnings.
What exception fires when no arm matches at runtime?SwitchExpressionException.
What warning fires for an arm that can never be reached?CS8510.
What method powers a positional pattern?Deconstruct, generated automatically for positional records.
Can IEnumerable<T> be matched with a list pattern?Not directly; it lacks length and an indexer, so materialize it first.
What keyword restricts a hierarchy's direct subtypes to one assembly in C# 15?closed.
What C# 15 keyword composes unrelated types into one closed set?union.
Does a when guard count toward exhaustiveness?No, guarded arms never prove exhaustiveness (CS8846).
Is a positional pattern match slower than manual property access?No, it compiles to the same Deconstruct call either way.

How to Prepare#

  • Write a nontrivial switch expression from memory that nests type, property, relational and list patterns in one arm.
  • Practice explaining, out loud, when you would choose pattern matching over polymorphism for a specific scenario, not just in the abstract.
  • Be ready to state the current status of native union types accurately, including whether the feature has shipped in the version you are discussing.
  • Rehearse a from-scratch Result<T, TError> implementation, including why a throwing .Value accessor defeats its purpose.
  • Review the exact CS85xx and CS8846 diagnostics: what triggers each one and what it implies about exhaustiveness.
  • Prepare one production example where guard clauses hid a coverage gap, or one where you deliberately used a discard arm that throws.