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 stackedcaselabels in C#, but on a single line.q when q > 10 ->is a guard:qcaptures the tested value in a variable, and thewhenclause is only evaluated if no earlier case matched. This is what replaces the?:tucked insidedefault— without ever having to step outside thematchconstruct to express it._is the wildcard: it matches anything not caught by any earlier case, like aswitch'sdefault. 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; matchis always an expression in F# — there's only one form to know, where C# distinguishes aswitchstatement from aswitchexpression;- in F#, the compiler warns by default as soon as a
matchdoesn't cover every possible case; in C#, that warning only exists for theswitchexpression, not for the olderswitchstatement 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"ifaandbare equal;"The first is greater"ifa > 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.