C# (.NET 8 LTS) track

Lesson 21 · Part 5: Modern C#

Pattern Matching

Use modern pattern matching to make type checks and branching safer and clearer.

Part 5: Modern C#5 min readFree lesson

Learning Objectives

  • Check a type and declare a variable in one step with the is pattern
  • Use switch with type, property, tuple and list patterns
  • Add conditions with the when clause.
  • See how pattern matching improves readability and safety.

Key concepts

if (obj is string s)
{
    Console.WriteLine(s.Length);   //s has already been cast to string
}

The boilerplate of the previous style if (obj is string) { string s = (string)obj; ... } is gone. Can also be combined with is not, and, or.

if (n is int x and > 0) ...
if (c is 'a' or 'A') ...

Since it is an expression, it can be directly substituted into a variable. default is replaced by _ (discard).

string label = day switch
{
    1 => "month", 2 => "angry", 3 => "number",
    _ => "other",
};
string sound = animal switch
{
    Dog d  => $"{d.Name} Meow",
    Cat c  => $"{c.Name} Meow",
    _      => "Quiet...",
};
string zone = point switch
{
    { X: 0, Y: 0 }     => "Origin",
    { X: 0 }           => "Above Y axis",
    { Y: 0 }           => "Above X axis",
    { X: > 0, Y: > 0 } => "1Quadrant",
    _                  => "other",
};

Comparison operators (>, <=...) can also be used as is.

(int x, int y) p = (1, 2);
string s = (x, y) switch
{
    (0, 0)                     => "Origin",
    var (a, b) when a == b     => "Diagonal",
    _                          => "other",
};
int[] arr = [1, 2, 3];
string s = arr switch
{
    []                       => "empty",
    [var only]               => $"only one: {only}",
    [1, .., 3]               => "Starts with 1 Ends with 3",
    [_, _, _]                => "exactly three",
    [var first, .. var rest] => $"First {first}, the rest {rest.Length}",
};

.. is "Anything in between is OK", and var name is received as the Slice/Element variable.

string s = age switch
{
    int a when a < 0   => "Invalid value",
    int a when a < 20  => "Youth",
    _                  => "adult",
};

See as example

foreach (object item in items)
{
    if (item is string s)        Console.WriteLine($"String: {s}");
    else if (item is int n and > 0) Console.WriteLine($"Positive integer: {n}");
    else if (item is double d)   Console.WriteLine($"Real number: {d}");
    else                         Console.WriteLine($"Other: {item}");
}

Execution result

String (5 characters): hello
Positive integer: 42
Real number: 3.14
Other: True
String (5 characters): world

Note: Like is int n and > 0, type and value conditions can be combined to pack a lot of meaning into one line.

static string Describe(Animal a) => a switch
{
    Dog d  => $"{d.Name}: Meow",
    Cat c  => $"{c.Name}: Meow",
    Bird b => $"{b.Name}: Chirp",
    _      => "Unknown animal",
};

Execution result

Baduki: Meow
Butterfly: Meow
Tweet: Tweet
Jjong: Meow

Note: Compare with the old polymorphism virtual Sound(). Pattern matching is cleaner when adding fewer animal types and branching logic is gathered in one place.

string zone = p switch
{
    { X: 0, Y: 0 }     => "Origin",
    { X: 0 }           => "Above Y axis",
    { Y: 0 }           => "Above X axis",
    { X: > 0, Y: > 0 } => "1Quadrant",
    { X: < 0, Y: < 0 } => "3Quadrant",
    _                  => "Other Quadrants",
};

Execution result

(0, 0) → Origin
(0, 5) → On Y axis
(7, 0) → On X axis
(3, 4) → 1st quadrant
(-1, -2) → 3rd quadrant

Note: Branching of the expression switch picks the matching item first from top to bottom. Please put more specific conditions above.

string result = (p1, p2) switch
{
    ("rock",     "scissors") => "P1 wins",
    ("paper",    "rock")     => "P1 wins",
    ("scissors", "paper")    => "P1 wins",
    var (a, b) when a == b   => "draws",
    _                        => "P2 wins",
};

Execution result

rock vs scissors: P1 win
paper vs rock: P1 win
scissors vs scissors: draw
rock vs paper: P2 win

Memo: The rule table can be expressed as code. Attach variable decomposition conditions to var (a, b) when ....

string desc = arr switch
{
    []              => "empty",
    [var only]      => $"One element ({only})",
    [1, .., 3]      => "Starts with 1 Ends with 3",
    [_, _, _]       => "exactly three",
    [var first, .. var rest] => $"First {first}, the rest {rest.Length}",
};

Execution result

[] → Empty
[42] → One element (42)
[1,2,3] → Start with 1 End with 3
[1,99,3] → Start with 1 End with 3
[1,2,3,4,5] → Start with 1 End with 3 End
[9,8,7] → Exactly three

Note: Since [1, .., 3] is above [_, _, _], [1,2,3] is also caught in the first branch. Always be conscious of your priorities.

Common Mistakes

  1. Do not cover all paths in the switch expression — If default (_) is not present, MatchFailureException is possible. The compiler also warns.
  2. Code where a more general pattern is placed on top, so the more specific pattern below cannot be reached.
  3. Omitting null check in property pattern — obj switch { { Prop: ... } => ... } does not match if obj is null. If that's your intention, OK, otherwise branch separately.
  4. By overusing the when clause, the branch actually becomes if/else if, which reduces readability — in that case, it is better to just use if.
  5. Use the list pattern for large collections — the pattern indexes by length, so it is better suited to Array/List than IEnumerable.

Cleanup

  • is pattern = type check variable declaration additional condition
  • The switch expression returns a value, so the var x = obj switch { ... }; form is natural.
  • When combining the Type/Attribute/Tuple/List pattern, the branch logic draws the data shape as is.
  • branch order has meaning — more specific patterns above
  • The when section is the last resort. If you use too much, if is better for readability.

Try it yourself

cd src/IsPattern && dotnet run
cd ../SwitchType && dotnet run
cd ../PropertyPattern && dotnet run
cd ../TuplePattern && dotnet run
cd ../ListPattern && dotnet run

Example files

The runnable files used in this lesson. Copy them into your own project and experiment.

src/IsPattern/IsPattern.csprojText
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <RootNamespace>CodingNow.Lecture.Modern21</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>
src/IsPattern/Program.csC#
//is pattern — Type check variable declaration at once.
object[] items = ["hello", 42, 3.14, true, "world"];

foreach (object item in items)
{
    //declaration pattern: If it matches the type, it is stored as cast to variable s.
    if (item is string s)
    {
        Console.WriteLine($"String ({s.Length} characters): {s}");
    }
    //Combination is also possible
    else if (item is int n and > 0)
    {
        Console.WriteLine($"Positive integer: {n}");
    }
    else if (item is double d)
    {
        Console.WriteLine($"Real number: {d}");
    }
    else
    {
        Console.WriteLine($"Other: {item}");
    }
}
src/ListPattern/ListPattern.csprojText
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <RootNamespace>CodingNow.Lecture.Modern21</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>
src/ListPattern/Program.csC#
//List/Array Pattern (.NET 7) — Checks both length and position.
int[][] arrays =
[
    [],
    [42],
    [1, 2, 3],
    [1, 99, 3],
    [1, 2, 3, 4, 5],
    [9, 8, 7],
];

foreach (int[] arr in arrays)
{
    string desc = arr switch
    {
        []              => "empty",
        [var only]      => $"One element ({only})",
        [1, .., 3]      => "Starts with 1 Ends with 3",
        [_, _, _]       => "exactly three",
        [var first, .. var rest] => $"First {first}, the rest {rest.Length}",
    };
    Console.WriteLine($"[{string.Join(",", arr)}] → {desc}");
}
src/PropertyPattern/Program.csC#
//property pattern — directly matches the property value of the object.
Point[] points = [new(0, 0), new(0, 5), new(7, 0), new(3, 4), new(-1, -2)];

foreach (Point p in points)
{
    string zone = p switch
    {
        { X: 0, Y: 0 }            => "Origin",
        { X: 0 }                  => "Above Y axis",
        { Y: 0 }                  => "Above X axis",
        { X: > 0, Y: > 0 }        => "1Quadrant",
        { X: < 0, Y: < 0 }        => "3Quadrant",
        _                         => "Other Quadrants",
    };
    Console.WriteLine($"({p.X}, {p.Y}) → {zone}");
}

internal sealed record Point(int X, int Y);
src/PropertyPattern/PropertyPattern.csprojText
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <RootNamespace>CodingNow.Lecture.Modern21</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>
src/SwitchType/Program.csC#
//switch expression type pattern — suitable for branching once in an inheritance tree.
Animal[] zoo = [new Dog("Baduki"), new Cat("butterfly"), new Bird("Tweet"), new Dog("Zong")];

foreach (Animal a in zoo)
{
    string sound = Describe(a);
    Console.WriteLine(sound);
}

static string Describe(Animal a) => a switch
{
    Dog d  => $"{d.Name}: Meow",
    Cat c  => $"{c.Name}: Meow",
    Bird b => $"{b.Name}: Chirp",
    _      => "Unknown animal",   //discard pattern — everything else
};

internal abstract record Animal(string Name);
internal sealed record Dog(string Name) : Animal(Name);
internal sealed record Cat(string Name) : Animal(Name);
internal sealed record Bird(string Name) : Animal(Name);
src/SwitchType/SwitchType.csprojText
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <RootNamespace>CodingNow.Lecture.Modern21</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>
src/TuplePattern/Program.csC#
//Tuple pattern — Matching multiple values ​​simultaneously. Rock-Paper-Scissors rules at a glance.
(string, string)[] games =
[
    ("rock", "scissors"),
    ("paper", "rock"),
    ("scissors", "scissors"),
    ("rock", "paper"),
];

foreach ((string p1, string p2) in games)
{
    string result = (p1, p2) switch
    {
        ("rock",     "scissors") => "P1 wins",
        ("paper",    "rock")     => "P1 wins",
        ("scissors", "paper")    => "P1 wins",
        var (a, b) when a == b   => "draws",        //Additional conditions with when clause
        _                        => "P2 wins",
    };
    Console.WriteLine($"{p1,-8} vs {p2,-8} : {result}");
}
src/TuplePattern/TuplePattern.csprojText
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <RootNamespace>CodingNow.Lecture.Modern21</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>

Assignment

  • Reference solution: shown below
  • Key concepts: type pattern, switch expression, record

Requirements

  • Define the following shape as record.
    • Circle(double Radius)
    • Rectangle(double Width, double Height)
    • Triangle(double Base, double Height)
  • Common abstraction: abstract record Shape;
  • Write the static double Area(Shape s) method as switch expression type pattern.
  • Output the results for the following array.
    Shape[] shapes = [new Circle(2), new Rectangle(3, 4), new Triangle(5, 6)];
    

Expected output

Circle(2) Area: 12.57
Rectangle(3, 4) Area: 12.00
Triangle(5, 6) Area: 15.00

Hint

  • Circle area = Math.PI * r * r.

  • Triangle = 0.5 * base * height.

  • The output is Area.ToString("F2") or $"{Area:F2}".

  • Reference solution: shown below

  • Key concepts: property pattern, when clause

Requirements

  • Define record Point(int X, int Y);.
  • Write static string Classify(Point p) as a switch expression property pattern.
  • Classification rules:
    • (0, 0) → "Origin"
  • X == 0 and Y != 0 → "Y-axis"
  • Y == 0 and X != 0 → "X-axis"
  • X == Y (and not 0,0) → "y=x Straight line"
  • Other positive numbers only → "1st quadrant"
  • Only negative numbers → "3rd quadrant"
  • Others → "Other quadrants"
  • Called with the following array:
    Point[] points = [new(0,0), new(0,5), new(7,0), new(3,3), new(2,8), new(-1,-5), new(-3,4)];
    

Expected output

(0,0) → origin
(0,5) → Y axis
(7,0) → X-axis
(3,3) → y=x straight line
(2,8) → 1st quadrant
(-1,-5) → 3rd quadrant
(-3,4) → Other quadrants

Hint

  • Branch order is important — (0,0) first.
  • Pull out variables like { X: var x, Y: var y } when x == y and compare.
  • Quadrants at once with { X: > 0, Y: > 0 }.

Check the correct answer

After solving it yourself, compare it with the reference solution below.

Show the reference solution

Try the assignment yourself first — comparing your attempt with a working solution is where most of the learning happens.

Homework01/Homework01.csprojText
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <RootNamespace>CodingNow.Lecture.Modern21</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>
Homework01/Program.csC#
Shape[] shapes = [new Circle(2), new Rectangle(3, 4), new Triangle(5, 6)];

foreach (Shape s in shapes)
{
    string label = s switch
    {
        Circle c    => $"Circle({c.Radius})",
        Rectangle r => $"Rectangle({r.Width}, {r.Height})",
        Triangle t  => $"Triangle({t.Base}, {t.Height})",
        _           => "Unknown",
    };
    Console.WriteLine($"{label} Area: {Area(s):F2}");
}

//Type pattern switch expression — Even if new shapes increase, you only need to change one place.
static double Area(Shape s) => s 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 InvalidOperationException($"Not supported: {s.GetType().Name}"),
};

internal abstract record Shape;
internal sealed record Circle(double Radius) : Shape;
internal sealed record Rectangle(double Width, double Height) : Shape;
internal sealed record Triangle(double Base, double Height) : Shape;
Homework02/Homework02.csprojText
<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <OutputType>Exe</OutputType>
    <TargetFramework>net8.0</TargetFramework>
    <RootNamespace>CodingNow.Lecture.Modern21</RootNamespace>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
  </PropertyGroup>

</Project>
Homework02/Program.csC#
Point[] points =
[
    new(0, 0), new(0, 5), new(7, 0), new(3, 3),
    new(2, 8), new(-1, -5), new(-3, 4),
];

foreach (Point p in points)
{
    Console.WriteLine($"({p.X},{p.Y}) → {Classify(p)}");
}

//Branch order is key — put the most specific (0,0) at the top.
static string Classify(Point p) => p switch
{
    { X: 0, Y: 0 }                          => "Origin",
    { X: 0 }                                => "Y-axis",
    { Y: 0 }                                => "X-axis",
    { X: var x, Y: var y } when x == y      => "y=x straight line",
    { X: > 0, Y: > 0 }                      => "1Quadrant",
    { X: < 0, Y: < 0 }                      => "3Quadrant",
    _                                       => "Other Quadrants",
};

internal sealed record Point(int X, int Y);

Learn this lesson with a mentor

Work through the C# (.NET 8 LTS) track on your own, or join a guided cohort at our Pitampura campus and online.

Call NowEnroll Now