Skip to content

Pattern Matching Interview Questions & Answers

15 questions Updated 2026-06-23 Share:

C# pattern matching interview questions — switch expressions, type patterns, property patterns, list patterns, and when guards.

Read the in-depth guideC# Pattern Matching and Switch Expressions(opens in new tab)
15 of 15

Pattern matching is a language feature that lets you test a value against a pattern — a shape, type, or condition — and extract data from it in a single expression. C# introduced basic patterns in C# 7 and added richer forms in each subsequent version through C# 11.

object value = 42;

// Pre-C# 7 style (verbose):
if (value is int)
{
    int n = (int)value;
    Console.WriteLine(n * 2);
}

// C# 7 — type pattern + declaration in is-expression:
if (value is int n)
    Console.WriteLine(n * 2); // 84 — n is declared and bound in one step

// C# 8 — switch expression replaces switch statement:
string describe = value switch
{
    int i when i < 0 => "negative",
    int i when i == 0 => "zero",
    int i => $"positive: {i}",
    string s => $"string: {s}",
    _ => "other"
};
Console.WriteLine(describe); // positive: 42

Rule of thumb: Prefer pattern matching over chains of if (x is T) { var t = (T)x; } casts. It is safer (no invalid cast exceptions), more concise, and exhaustive in switch expressions (compiler warns if cases are missing).

A switch statement is imperative — it executes side-effect branches. A switch expression (C# 8) is an expression — it produces a value. It is more concise, discourages fall-through, and the compiler enforces exhaustiveness (warns if any input has no matching arm).

// switch statement (C# 1+):
string desc;
switch (statusCode)
{
    case 200: desc = "OK";           break;
    case 404: desc = "Not Found";    break;
    case 500: desc = "Server Error"; break;
    default:  desc = "Unknown";      break;
}

// switch expression (C# 8+) — same logic, 7 lines fewer:
string desc2 = statusCode switch
{
    200 => "OK",
    404 => "Not Found",
    500 => "Server Error",
    _   => "Unknown",        // discard pattern = default
};

// switch expression returning complex types:
decimal tax = orderType switch
{
    OrderType.Standard  => subtotal * 0.20m,
    OrderType.Reduced   => subtotal * 0.05m,
    OrderType.ZeroRated => 0m,
    _ => throw new ArgumentOutOfRangeException(nameof(orderType))
};

The _ pattern is the discard (wildcard / default). Omitting it when not all cases are covered produces a warning, and the expression will throw SwitchExpressionException at runtime for unhandled values.

Rule of thumb: Prefer switch expressions over switch statements whenever you are computing a value. Use switch statements only for side-effect branches that don't produce a return value.

A type pattern tests whether a value is of a specific type and, if so, assigns it to a new typed variable — replacing the is check + cast idiom.

object[] items = { 42, "hello", 3.14, true, null };

foreach (var item in items)
{
    // Type pattern in is-expression:
    if (item is int n)
        Console.WriteLine($"int: {n}");
    else if (item is string s)
        Console.WriteLine($"string: {s}");

    // Type pattern in switch expression:
    string describe = item switch
    {
        int i    => $"integer {i}",
        string s => $"string '{s}'",
        double d => $"double {d:F2}",
        null     => "null",
        _        => item.GetType().Name
    };
    Console.WriteLine(describe);
}

// Inheritance works — tests the runtime type:
Shape shape = new Circle { Radius = 5 };
if (shape is Circle c)
    Console.WriteLine($"Circle with radius {c.Radius}"); // matches

The declared variable (n, s, c) is only in scope when the pattern matches — and it is already cast to the correct type, so no explicit cast is needed.

Rule of thumb: Use type patterns instead of is + explicit cast. They are null-safe (null never matches a type pattern), and the variable is guaranteed to be non-null when the pattern succeeds.

A property pattern (C# 8) matches an object by testing the values of its properties or fields. It uses { PropertyName: pattern } syntax and can be nested arbitrarily deep.

record Address(string Country, string City);
record Customer(string Name, Address Address, bool IsPremium);

Customer customer = new("Alice", new Address("UK", "London"), IsPremium: true);

// Property pattern — match multiple properties:
string shipping = customer switch
{
    { IsPremium: true, Address.Country: "UK" } => "Free UK delivery",
    { IsPremium: true }                         => "Free international delivery",
    { Address.Country: "UK" }                   => "£2.99 UK delivery",
    _                                            => "£4.99 international delivery"
};
Console.WriteLine(shipping); // Free UK delivery

// Nested property pattern (C# 10 — extended property pattern):
// { Address: { Country: "UK" } }  — verbose C# 8
// { Address.Country: "UK" }       — terse C# 10+ (dot notation)

// Combine with type pattern:
object obj = customer;
if (obj is Customer { Name: var name, IsPremium: true })
    Console.WriteLine($"Premium: {name}");

Rule of thumb: Property patterns shine in switch expressions for business-rule dispatching based on data shape. Keep them flat — deeply nested property patterns hurt readability more than they gain.

A positional pattern (C# 8) matches values by position using a type's Deconstruct method (or a record's built-in deconstruction). It is written as TypeName(pattern1, pattern2, ...).

// Records have built-in deconstruction:
record Point(int X, int Y);

Point p = new(3, -1);

// Positional pattern — matches based on deconstructed positions:
string quadrant = p switch
{
    (0, 0)          => "Origin",
    (> 0, > 0)      => "Q1",
    (< 0, > 0)      => "Q2",
    (< 0, < 0)      => "Q3",
    (> 0, < 0)      => "Q4",
    _               => "On axis"
};
Console.WriteLine(quadrant); // Q4

// Custom Deconstruct for non-record types:
class Rectangle
{
    public int Width { get; init; }
    public int Height { get; init; }
    public void Deconstruct(out int w, out int h) => (w, h) = (Width, Height);
}

Rectangle r = new() { Width = 10, Height = 5 };
if (r is (> 0, > 0) and (var w, var h))
    Console.WriteLine($"{w} x {h}"); // 10 x 5

Rule of thumb: Positional patterns are most useful with records or small tuples where the position is the natural identity. For larger objects with many properties, property patterns are more readable (explicit names).

Relational patterns (C# 9) let you compare a value with <, <=, >, >= inside a pattern. Logical patterns (and, or, not) combine patterns.

int score = 85;

// Relational patterns:
string grade = score switch
{
    >= 90           => "A",
    >= 80 and < 90  => "B",   // logical 'and' — both must match
    >= 70 and < 80  => "C",
    >= 60 and < 70  => "D",
    _               => "F"
};
Console.WriteLine(grade); // B

// Logical 'or':
bool isWeekend = DateTime.Now.DayOfWeek switch
{
    DayOfWeek.Saturday or DayOfWeek.Sunday => true,
    _ => false
};

// Logical 'not' (with type patterns):
object obj = "hello";
if (obj is not null)                  Console.WriteLine("not null");
if (obj is not int)                   Console.WriteLine("not an integer");
if (obj is not (int or double))       Console.WriteLine("not numeric");

// Combining relational and property patterns:
record Product(string Name, decimal Price);
var p = new Product("Widget", 24.99m);
bool isAffordable = p is { Price: >= 10 and <= 50 }; // true

Rule of thumb: Relational and logical patterns make range-based and exclusion conditions read naturally in switch expressions. Prefer >= 80 and < 90 over a when-guard when score >= 80 && score < 90.

A when clause adds an extra boolean condition to a switch arm. The arm only matches if the pattern matches and the when condition is true. It is useful for conditions that cannot be expressed purely as a pattern.

var orders = new[]
{
    new { Id = 1, Total = 200m, IsPaid = true  },
    new { Id = 2, Total = 50m,  IsPaid = false },
    new { Id = 3, Total = 0m,   IsPaid = false },
};

foreach (var order in orders)
{
    string status = order switch
    {
        { IsPaid: true, Total: > 100 } => "High-value paid",
        { IsPaid: true }               => "Paid",
        { Total: 0 }                   => "Empty order",
        var o when o.Total > 100       => "High-value — awaiting payment",
        _                              => "Pending payment"
    };
    Console.WriteLine($"Order {order.Id}: {status}");
}
// Order 1: High-value paid
// Order 2: Pending payment
// Order 3: Empty order

// switch statement with when:
switch (exception)
{
    case HttpRequestException ex when ex.StatusCode == HttpStatusCode.NotFound:
        HandleNotFound(); break;
    case HttpRequestException ex:
        HandleOtherHttpError(ex); break;
    default:
        throw;
}

Rule of thumb: Use when guards for conditions that involve method calls, complex logic, or state not captured by the matched object's properties. Keep guards simple — a complex when clause signals the logic should move into a method.

A list pattern (C# 11) matches an array, list, or any type implementing a suitable indexer and Count/Length, against a sequence of element patterns. The .. slice pattern matches zero or more elements in the middle.

int[] empty   = [];
int[] one     = [1];
int[] two     = [1, 2];
int[] several = [1, 2, 3, 4, 5];

// Exact match:
Console.WriteLine(empty   is []);        // True
Console.WriteLine(one     is [1]);       // True
Console.WriteLine(two     is [1, 2]);    // True

// Length check:
Console.WriteLine(several is [_, _, _]); // False — not exactly 3

// Slice:
Console.WriteLine(several is [1, .., 5]);       // True — starts 1, ends 5
Console.WriteLine(several is [var first, ..]);   // True — bind first element
Console.WriteLine(first);                         // 1

// Destructure specific positions:
if (several is [var head, .. var middle, var last])
{
    Console.WriteLine($"head={head}, last={last}, middle count={middle.Length}");
    // head=1, last=5, middle count=3
}

// HTTP method routing example:
string[] segments = ["api", "users", "42"];
string route = segments switch
{
    ["api", "users", var id] => $"Get user {id}",
    ["api", "users"]         => "List users",
    _                        => "Unknown route"
};
Console.WriteLine(route); // Get user 42

Rule of thumb: List patterns simplify sequence destructuring that would otherwise require index-based checks. Most useful for small, fixed-structure arrays like command-line args, URL segments, or parsed CSV rows.

Deconstruction lets you unpack an object's components into discrete variables in a single assignment. Records generate a Deconstruct method automatically; other types implement it as a void Deconstruct(out T1 a, out T2 b, ...) method.

// Records — automatic Deconstruct:
record Point(int X, int Y);
var p = new Point(3, 7);
var (x, y) = p;                  // deconstruct
Console.WriteLine($"x={x}, y={y}"); // x=3, y=7

// Custom class with Deconstruct:
class Rectangle
{
    public int Width  { get; init; }
    public int Height { get; init; }

    public void Deconstruct(out int width, out int height)
        => (width, height) = (Width, Height);
}

var rect = new Rectangle { Width = 10, Height = 5 };
var (w, h) = rect;              // uses Deconstruct
Console.WriteLine($"{w}x{h}"); // 10x5

// Tuples are deconstructable out of the box:
(string name, int age) person = ("Alice", 30);
var (n, a) = person;

// Use _ to discard fields you don't need:
var (_, height) = rect;         // only care about height

// Deconstruction in foreach:
var people = new[] { ("Alice", 30), ("Bob", 25) };
foreach (var (name, age) in people)
    Console.WriteLine($"{name} is {age}");

Rule of thumb: Implement Deconstruct on any type that has a natural set of component parts — geometry, coordinates, key-value pairs. Prefer records over manual implementation when the type is primarily data-carrying.

The var pattern always succeeds and binds the value (including null) to a new variable, inferring its type. Unlike type patterns, it matches anything — even null.

// var pattern in switch expression — capture for use in when guard:
object obj = "hello";
string result = obj switch
{
    null              => "null",
    var s when s is string str && str.Length > 3 => $"long: {str}",
    var x             => $"other: {x}"
};

// Common use — compute intermediate value for a when guard:
int[] nums = { 1, 2, 3, 4, 5, 6 };
var grouped = nums.GroupBy(n => n % 2 == 0 ? "even" : "odd")
    .Select(g => g switch
    {
        var group when group.Count() > 2 => $"{group.Key}: many",
        var group                         => $"{group.Key}: few"
    });

// var in deconstruction-style pattern:
Point p = new(5, -3);
if (p is (var px, < 0)) // positional + var to capture X, relational for Y
    Console.WriteLine($"Below x-axis at x={px}");

The var pattern is mainly used to bind a value for use in a when guard, or to capture a subvalue within a nested pattern that would otherwise be verbose to repeat.

Rule of thumb: Use the var pattern specifically when you need to give a name to a matched value for use in a when clause. For general type-checking, prefer a type pattern which also ensures non-null.

Records are ideal pattern-matching targets because they have built-in value equality, automatic deconstruction, and immutable properties — all properties pattern matching needs to work cleanly.

// Records support property, positional, and type patterns:
record Shape;
record Circle(double Radius) : Shape;
record Rectangle(double Width, double Height) : Shape;
record Triangle(double Base, double Height) : Shape;

double Area(Shape shape) => shape switch
{
    Circle c               => Math.PI * c.Radius * c.Radius,
    Rectangle(var w, var h) => w * h,              // positional
    Triangle { Base: var b, Height: var h } => 0.5 * b * h, // property
    _ => throw new ArgumentException($"Unknown shape: {shape}")
};

Console.WriteLine(Area(new Circle(5)));          // ~78.54
Console.WriteLine(Area(new Rectangle(4, 6)));    // 24
Console.WriteLine(Area(new Triangle(3, 8)));     // 12

// Nested record patterns:
record Address(string Country, string City);
record Order(int Id, Address ShipTo, decimal Total);

var order = new Order(1, new Address("UK", "London"), 299.99m);
string delivery = order switch
{
    { ShipTo.Country: "UK", Total: > 100 }  => "Free UK delivery",
    { ShipTo.Country: "UK" }                => "£3.99 UK delivery",
    { Total: > 200 }                         => "Free international",
    _                                        => "£9.99 international"
};
Console.WriteLine(delivery); // Free UK delivery

Rule of thumb: Design your domain model with records when you know it will be pattern-matched. The combination of value equality, deconstruction, and immutability makes records the natural complement to switch expressions.

C# pattern matching is null-safe by design. Type patterns never match null (a null value does not have a type in the pattern sense). The null literal pattern explicitly matches null. The not null pattern is the clean way to check non-null.

object? value = null;

// Type pattern never matches null:
if (value is string s)   // false — null is not a string
    Console.WriteLine(s);

// Explicit null pattern:
string desc = value switch
{
    null    => "was null",   // explicit null arm
    int n   => $"int: {n}",
    string s => $"string: {s}",
    _       => "other"
};
Console.WriteLine(desc); // was null

// not null pattern (C# 9):
if (value is not null) Console.WriteLine("has value");

// Property pattern on nullable reference type:
string? name = null;
if (name is { Length: > 0 })  // safe — false for null, no NullReferenceException
    Console.WriteLine($"Name: {name}");

// Preferred null check in modern C#:
string? s2 = GetValue();
string upper = s2 is { Length: > 0 } ? s2.ToUpper() : "(empty)";

Rule of thumb: Use is null and is not null instead of == null in modern C# — they work correctly with operator-overloaded types and communicate intent more clearly in pattern-matching contexts.

A constant pattern tests whether a value equals a compile-time constant — a literal number, string, character, boolean, enum value, or null. It is the pattern equivalent of an equality check and is used implicitly in most switch arms.

// Constant patterns in a switch expression:
int code = 404;
string message = code switch
{
    200 => "OK",
    301 => "Moved Permanently",
    404 => "Not Found",           // constant pattern: matches integer 404
    500 => "Internal Server Error",
    _   => "Unknown"
};
Console.WriteLine(message); // Not Found

// Constant pattern in is-expression:
object obj = true;
if (obj is true)    Console.WriteLine("it is true");
if (obj is false)   Console.WriteLine("it is false");

// Enum constant pattern:
DayOfWeek day = DayOfWeek.Monday;
bool isWeekday = day switch
{
    DayOfWeek.Saturday or DayOfWeek.Sunday => false,
    _ => true  // all other constant enum values
};

// String constant pattern (case-sensitive):
string role = "Admin";
bool canDelete = role switch
{
    "Admin"     => true,
    "Moderator" => false,
    _           => false
};

// Note: constant patterns use == semantics including operator overloads.
// For string comparisons, use when guards if you need case-insensitive matching:
bool isAdmin = role switch
{
    var r when r.Equals("admin", StringComparison.OrdinalIgnoreCase) => true,
    _ => false
};

Rule of thumb: Constant patterns are implicit in switch arms — every literal value in a switch is a constant pattern. For strings that need case-insensitive matching, fall back to a when guard because constant patterns always use ordinal, case-sensitive equality.

A switch expression is exhaustive when the compiler can prove every possible input is covered by some arm. If a value reaches a switch expression with no matching arm at runtime, a SwitchExpressionException is thrown. The compiler warns at build time when exhaustiveness cannot be proven.

// Compiler warns: switch expression does not handle all values of 'DayOfWeek'
string kind = DateTime.Now.DayOfWeek switch
{
    DayOfWeek.Saturday => "Weekend",
    DayOfWeek.Sunday   => "Weekend",
    // Warning: missing cases for Monday–Friday
};

// Fix 1: add explicit arms for all values
string kind2 = DateTime.Now.DayOfWeek switch
{
    DayOfWeek.Saturday or DayOfWeek.Sunday => "Weekend",
    _                                        => "Weekday"  // discard covers the rest
};

// Fix 2: throw for unexpected values — documents intent
OrderStatus status = GetStatus();
string label = status switch
{
    OrderStatus.Pending   => "Awaiting payment",
    OrderStatus.Confirmed => "Processing",
    OrderStatus.Shipped   => "On the way",
    OrderStatus.Delivered => "Delivered",
    _ => throw new ArgumentOutOfRangeException(nameof(status), status, null)
    // Warning: if a new enum value is added and this switch is not updated,
    // the runtime exception surfaces immediately.
};

// Sealed class hierarchies: compiler can verify exhaustiveness over subtypes
abstract record Shape;
sealed record Circle(double Radius) : Shape;
sealed record Square(double Side)  : Shape;

double area = new Circle(5) switch
{
    Circle c => Math.PI * c.Radius * c.Radius,
    Square s => s.Side * s.Side
    // No discard needed — compiler knows only Circle and Square exist
};

Rule of thumb: Always include a _ (discard) arm that throws ArgumentOutOfRangeException when matching enums or open class hierarchies. This turns a silent missing-case bug (wrong result) into a loud runtime failure — and with sealed hierarchies the compiler enforces exhaustiveness for you.

The C# compiler and JIT optimizer treat switch expressions and pattern-matching constructs as opportunities to generate jump tables, binary searches, or type-check inlining — often significantly faster than equivalent if-else chains.

// if-else chain — sequential: O(n) comparisons in the worst case
string DescribeIf(int n)
{
    if (n == 1) return "one";
    if (n == 2) return "two";
    if (n == 3) return "three";
    return "other";
}

// switch expression on integers — compiler may emit a jump table: O(1)
string DescribeSwitch(int n) => n switch
{
    1 => "one",
    2 => "two",
    3 => "three",
    _ => "other"
};

// Type patterns — compiler orders checks by frequency hints and inheritance depth.
// Sealed subtypes enable devirtualisation; the JIT can inline the check.
static double Area(Shape shape) => shape switch
{
    Circle c    => Math.PI * c.Radius * c.Radius,
    Rectangle r => r.Width * r.Height,
    _           => throw new NotSupportedException()
};
// Note: put the most common case first to reduce average comparison count.

// Property patterns — compiled to a sequence of property reads + comparisons;
// no special optimisation, but still cleaner than nested ifs.
bool IsHighValueUkOrder(Order o) =>
    o is { ShipTo.Country: "UK", Total: > 500 };
// Equivalent to: o.ShipTo.Country == "UK" && o.Total > 500

// Constant integer/char/string switch — jump table when values are dense,
// binary search when sparse. Both are faster than sequential if-else.

Rule of thumb: Prefer switch expressions over if-else chains for three or more constant alternatives — the compiler can emit faster code (jump tables / binary search). For type patterns, list the most frequently matched types first. Avoid micro-optimising pattern matching before profiling; the clarity gain alone usually justifies it.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel