Aller au contenu principal
Nicolas Cousin Tech SolutionsNicolas Cousin Tech Solutions
Module 5 of 7

Pattern Matching

The scenario

A simple pricing grid, in C#, with a switch on a quantity range:

string PricingFor(int quantity)
{
    switch (quantity)
    {
        case 0:
            return "No order";
        case 1:
        case 2:
        case 3:
            return "Unit price";
        default:
            return quantity > 10 ? "Bulk price" : "Standard price";
    }
}

Two mechanisms overlap here: switch for the exact cases, and a fallback ?: for the remaining range logic in default. F# unifies both into a single construct: the match expression.

(Modern C#, since C# 8, also offers a switch expression closer to match — but the statement form above is still very common in existing code, which is why it serves as the starting point here. You'll run into the switch expression again later in this module.)

The match expression

let pricingFor quantity =
    match quantity with
    | 0 -> "No order"
    | 1 | 2 | 3 -> "Unit price"
    | q when q > 10 -> "Bulk price"
    | _ -> "Standard price"

Three things worth noting compared to the C# switch:

  • 1 | 2 | 3 -> groups several values under a single case, like stacked case labels in C#, but on a single line.
  • q when q > 10 -> is a guard: q captures the tested value in a variable, and the when clause is only evaluated if no earlier case matched. This is what replaces the ?: tucked inside default — without ever having to step outside the match construct to express it.
  • _ is the wildcard: it matches anything not caught by any earlier case, like a switch's default. Without it (and unless every possible case is otherwise covered), the compiler emits an incomplete-match warning.

match is an expression, not a statement: it produces a value, just as if...then...else produces one in F#. There's never a need for a separate return in each branch: the value of the matching case directly becomes the value of the whole match expression.

Matching a tuple

One of the most useful cases for match is with tuples:

let detectZero point =
    match point with
    | (0, 0) -> "Both values are zero."
    | (0, y) -> $"The first value is zero, the second is {y}."
    | (x, 0) -> $"The second value is zero, the first is {x}."
    | _ -> "Neither value is zero."

Each pattern (0, 0), (0, y), (x, 0) compares the shape of the received tuple against a template: 0 is a literal that must match exactly, while y or x are variables that capture whatever value is present at that position. This is filtering and decomposition in a single step.

Modern C# (since C# 8) can do the same thing with the switch expression mentioned above, using a positional pattern directly on the tuple:

static string DetectZero((int, int) point) => point switch
{
    (0, 0) => "Both values are zero.",
    (0, var y) => $"The first value is zero, the second is {y}.",
    (var x, 0) => $"The second value is zero, the first is {x}.",
    _ => "Neither value is zero.",
};

Both versions read almost line for line: so this isn't a capability entirely absent from C#, contrary to what the plain switch statement in the previous section might suggest. Three differences remain, subtler than an outright impossibility:

  • in C#, every captured variable must be prefixed with var (var y, var x); in F#, the name alone is enough;
  • match is always an expression in F# — there's only one form to know, where C# distinguishes a switch statement from a switch expression;
  • in F#, the compiler warns by default as soon as a match doesn't cover every possible case; in C#, that warning only exists for the switch expression, not for the older switch statement seen at the start of this module.

Exercise — write your own match on a tuple

Your turn: write, in dotnet fsi, a comparePair function that takes a tuple (a, b) of two integers and returns:

  • "Equal" if a and b are equal;
  • "The first is greater" if a > b;
  • "The second is greater" otherwise.

Use match with guards (when), following the same principle as detectZero above — but this time, decompose and compare the two tuple elements yourself, without peeking at a solution before trying.

Test with:

comparePair (3, 3);;
comparePair (5, 2);;
comparePair (1, 9);;

Expected outputs, in order:

val it: string = "Equal"
val it: string = "The first is greater"
val it: string = "The second is greater"

If you're stuck: the first case to test must be equality ((a, b) when a = b), otherwise a > b would match first even when a and b are equal — the same ordering trap applies to any list of guards, as you'll see again in the next exercise.

Contrast with if/then/else

if...then...else remains perfectly valid in F# for a simple binary condition:

let isPositive x =
    if x >= 0 then "positive or zero"
    else "negative"

match becomes preferable as soon as you have more than two branches, or you're decomposing a structured value like a tuple: the equivalent logic in nested if/elif/else quickly loses readability, and doesn't decompose (x, y) into named variables as directly as a tuple pattern does.

Exercise — convert a C# if/else into an F# match

Here's a C# method that grades a score out of 20:

string Grade(int score)
{
    if (score < 10)
        return "Fail";
    else if (score < 12)
        return "Pass";
    else if (score < 16)
        return "Good";
    else
        return "Excellent";
}

Write, in dotnet fsi, an equivalent grade function in F#, using match with guards (when) rather than exact literal bounds — since the value isn't a fixed constant but a range, you can't filter it with a plain literal like | 10 ->.

Test it with these four calls:

grade 8;;
grade 11;;
grade 14;;
grade 18;;

Expected outputs, in order:

val it: string = "Fail"
val it: string = "Pass"
val it: string = "Good"
val it: string = "Excellent"

If grade 11 returns "Fail" instead of "Pass", your guards are misordered or mis-bounded: match evaluates cases in the order they're written and stops at the first one that matches, exactly like a switch's case labels or if/elif branches evaluate top to bottom.

What you just did

You replaced a C# if/else with an F# match expression, discovered how guards (when) cover cases a literal alone can't express, and saw how match decomposes a tuple in a single step — a capability modern C# also offers via the switch expression, but with a bit more ceremony (var before each captured variable, and a statement/expression distinction F# doesn't have).

In the next module, you'll discover a new data type, the F# record, and the question it immediately raises: what does it mean for "two records to be equal," and how does that fundamentally differ from a C# class's default equality?

Check your understanding

With detectZero point = match point with | (0, 0) -> ... | (0, y) -> ... | (x, 0) -> ... | _ -> ..., what text does detectZero (0, 7) return?

If you write match n with | _ -> "Other" | n when n < 10 -> "Small", what happens?

Modern C# (switch expression, since C# 8) can also decompose a tuple with (0, var y) => .... What difference still remains with the equivalent F# match?

Want to hear about the next modules?

The Academy stays free and open-access, no sign-up required. If you'd just like to be notified by email when a new module ships, here you go — no obligation, unsubscribe anytime with one click.