F# Module 4 – Modelling data with types
F# pattern matching in depth
Combine F# tuple, union, list, array, as, OR and AND patterns, guard rules with when, keep matches exhaustive and fix warnings FS0025 and FS0026.
What you will learn
- Combine tuple, union, constant and record patterns with guards in one match
- Write list, cons and array patterns that match values by their shape
- Write as, OR and AND patterns, and place a guard where it applies
- Fix warnings FS0025 and FS0026 and keep a match exhaustive without a wildcard
- Use function, let patterns and type-test patterns where each fits
Before you start
What you’ll learn · Before you start
What you will learn
- Combine tuple, union, constant and record patterns with guards in one match
- Write list, cons and array patterns that match values by their shape
- Write as, OR and AND patterns, and place a guard where it applies
- Fix warnings FS0025 and FS0026 and keep a match exhaustive without a wildcard
- Use function, let patterns and type-test patterns where each fits
Before you start
On this page
A fare depends on who travels and how far: seven trips, six rules, three guards. As nested if tests that is a
page of branches whose gaps nobody can see. As one match over a tuple it is a table that the compiler reads too: it
names a combination you forgot, with an example value, and the rule that can never run.
You will combine patterns for tuples, unions, lists and arrays, bind and join them with as, | and &, put guards
where they apply, and keep matches exhaustive as types grow. Every pattern here was run on .NET 10, and so were the
warnings that keep a match honest.
Patterns nest, so one match can read a whole value
A pattern can contain other patterns. fare matches a tuple whose first part is a union and whose second is a number,
so each rule reads like a row of a fare table: a union case, a constant or a name for each position, and a
guard: The when clause of a match rule, such as | a when a < 5 -> 0: a bool expression that must also be true for the rule to fit, evaluated only after the pattern has fitted. More in “if, match and loops as expressions” where a number must pass a test:
// One match over a tuple prices a ticket: union cases, constants, guards and an OR pattern, rule by rule.
type Passenger =
| Adult
| Child of age: int
| Senior
| Student
let fare (passenger, km: int) =
match passenger, km with
| _, km when km <= 0 -> None
| Child age, _ when age < 5 -> Some 0m
| (Child _ | Student), km -> Some(decimal km * 0.5m)
| Senior, km when km >= 500 -> Some(decimal km * 0.4m)
| Senior, km -> Some(decimal km * 0.6m)
| Adult, km -> Some(decimal km)
let trips = [ Adult, 120; Child 3, 120; Child 9, 120; Student, 300; Senior, 300; Senior, 800; Adult, 0 ]
for passenger, km in trips do
let price =
match fare (passenger, km) with
| Some amount -> $"{amount:F2} rupees"
| None -> "no fare: the distance must be positive"
printfn "%-10s %4d km %s" (sprintf "%A" passenger) km price from session import fsi, run
# fares.fsx, then the senior rules in the wrong order: once with the unguarded rule first, which the compiler
# reports, and once with two guards in the wrong order, which it cannot.
run("""
dotnet fsi fares.fsx
""")
fsi([
'type Passenger = Adult | Senior;;',
'let early (passenger, km: int) =\n match passenger, km with\n | Senior, km -> decimal km * 0.6m\n | Senior, km when km >= 500 -> decimal km * 0.4m\n | Adult, km -> decimal km;;',
'string (early (Senior, 800));;',
'let guarded (passenger, km: int) =\n match passenger, km with\n | Senior, km when km >= 100 -> decimal km * 0.6m\n | Senior, km when km >= 500 -> decimal km * 0.4m\n | Senior, km -> decimal km * 0.8m\n | Adult, km -> decimal km;;',
'string (guarded (Senior, 800));;',
'#quit;;',
], args=["--nologo"]) Output
$ dotnet fsi fares.fsx
Adult 120 km 120.00 rupees
Child 3 120 km 0.00 rupees
Child 9 120 km 60.00 rupees
Student 300 km 150.00 rupees
Senior 300 km 180.00 rupees
Senior 800 km 320.00 rupees
Adult 0 km no fare: the distance must be positive
$ dotnet fsi --nologo
> type Passenger = Adult | Senior;;
type Passenger =
| Adult
| Senior
> let early (passenger, km: int) =
- match passenger, km with
- | Senior, km -> decimal km * 0.6m
- | Senior, km when km >= 500 -> decimal km * 0.4m
- | Adult, km -> decimal km;;
| Senior, km when km >= 500 -> decimal km * 0.4m
------^^^^^^^^^^^^^^^^^^^^^^^^^
stdin(5,7): warning FS0026: This rule will never be matched
val early: passenger: Passenger * km: int -> decimal
> string (early (Senior, 800));;
val it: string = "480.0"
> let guarded (passenger, km: int) =
- match passenger, km with
- | Senior, km when km >= 100 -> decimal km * 0.6m
- | Senior, km when km >= 500 -> decimal km * 0.4m
- | Senior, km -> decimal km * 0.8m
- | Adult, km -> decimal km;;
val guarded: passenger: Passenger * km: int -> decimal
> string (guarded (Senior, 800));;
val it: string = "480.0"
> #quit;;
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 fares_session.py
The match tries the rules from the top and takes the first one whose pattern fits and whose guard is true, as the match expressions reference describes; the guard is evaluated only once its pattern has fitted. Step through the 800 km trip of a senior:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- One tuple. match passenger, km with matches one value, the tuple (Senior, 800). Each rule is a tuple pattern, read from the top.
- Pattern fits, guard fails. _, km fits any tuple and binds km to 800, but the guard km <= 0 is false, so the next rule is tried.
- Wrong case. Child age needs the case Child. A Senior does not fit, so the guard is never evaluated.
- Neither side of the OR. (Child _ | Student) fits either case, and a Senior is neither, so this rule is skipped as well.
- The rule that fits. Senior, km fits and km >= 500 is true, so the fare is 800 * 0.4, which fares.fsx printed as 320.00 rupees.
- Later rules wait. The two rules below are never looked at for this trip. Senior, km without a guard catches the shorter senior trips, 300 km for 180.00 rupees.
Order is part of the meaning. Senior, km when km >= 500 must come before Senior, km: the second half of the
session, early, put them the other way round, the compiler warned FS0026 that the guarded rule will never be matched,
and the 800 km trip cost 480.0 at the shorter-trip rate. Guards are needed whenever a part is compared with something
other than a literal: patterns can test Child 3 against a case, but not against age < 5. The same nesting works
for records, as in { Home = { City = "Pune" } }, which tests a field of a field.
Two guards in the wrong order
Wrong: Senior, km when km >= 100 above Senior, km when km >= 500, as in guarded. Every long trip also passes
the first guard, so the cheaper rule never runs, and nothing warns, because the compiler does not evaluate guards:
guarded (Senior, 800) gave 480.0. Right: the narrowest condition first, and the rule without a guard last.
Checkpoint
Check: Nested patterns and guards
List and array patterns match by shape
Lists have two kinds of pattern. A list pattern, [ first; second ], fits lists of exactly that length. A
cons pattern: A pattern written head :: tail that fits any list with at least one item: head is bound to the first item and tail to the list of the items after it, which may be empty., first :: rest, fits any list with a first item. Arrays have array
patterns of fixed length, [| x; 0 |]. shapes.fsx uses all three:
// List patterns match a fixed length, cons patterns any length, and array patterns a fixed length of an array.
let describe items =
match items with
| [] -> "empty"
| [ only ] -> $"one item, {only}"
| [ first; second ] -> $"two items, {first} and {second}"
| first :: rest -> $"starts with {first}, then {List.length rest} more"
for items in [ []; [ 7 ]; [ 7; 8 ]; [ 7; 8; 9; 10 ] ] do
printfn "%-16s %s" (sprintf "%A" items) (describe items)
let rec sumPairs items =
match items with
| a :: b :: rest -> (a + b) :: sumPairs rest
| [ last ] -> [ last ]
| [] -> []
printfn "sumPairs [1..5] = %A" (sumPairs [ 1..5 ])
let corner (point: int[]) =
match point with
| [| 0; 0 |] -> "the origin"
| [| x; 0 |] -> $"on the x axis at {x}"
| [| _; _ |] -> "somewhere in the plane"
| _ -> $"not a 2D point: {point.Length} values"
for p in [ [| 0; 0 |]; [| 5; 0 |]; [| 2; 3 |]; [| 1; 2; 3 |] ] do
printfn "%s" (corner p) from session import run
run("""
dotnet fsi shapes.fsx
""") Output
$ dotnet fsi shapes.fsx [] empty [7] one item, 7 [7; 8] two items, 7 and 8 [7; 8; 9; 10] starts with 7, then 3 more sumPairs [1..5] = [3; 7; 5] the origin on the x axis at 5 somewhere in the plane not a 2D point: 3 values
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 shapes_session.py
Step through the list of four items. The rules for fixed lengths come first, because first :: rest would also fit a
list of one or two items:
The drawing shows the whole scene; its 4 steps are listed below.
Every step as text
- Four items. describe is given a list of four numbers. Its rules are tried from the top, as in every match.
- Lengths that do not fit. [] fits only the empty list, [ only ] only a list of one item and [ first; second ] only a list of two, so all three are skipped.
- Head and tail. first :: rest fits any list that has a first item. It binds first to 7 and rest to [8; 9; 10], the list that follows.
- The result. The rule prints starts with 7, then 3 more. Rules for fixed lengths come first, because first :: rest would also fit a list of one or two items.
Cons patterns are how recursive functions walk a list: sumPairs takes two items at a time with a :: b :: rest,
handles a last odd item with [ last ], and stops at [], so [1..5] became [3; 7; 5]. Array patterns test
positions and lengths at once: [| x; 0 |] reads the first item and requires the second to be 0, and an array of
three values fell to the last rule, “not a 2D point: 3 values”.
Key idea
A list pattern fits one length and a cons pattern any length from one up, so put the fixed lengths first and let
head :: tail take the rest, with [] to end a recursion.
Checkpoint
Check: List, cons and array patterns
as, OR and AND patterns bind and combine
Three operators build patterns out of patterns. as binds the whole value a pattern matched to a name. | makes an
OR pattern: Two or more patterns joined by |, such as Upi _ | Card _, that share one rule. Every side must bind the same names with the same types, and a guard after it applies to all sides., which lets several patterns share one rule. &, the AND pattern, requires
the value to match two patterns at once. combine.fsx audits payments with the first two and shows a guard that
surprised its author:
// as binds the whole value, an OR pattern shares one result between patterns, named fields pick one field of a case,
// and a guard after an OR pattern applies to every side of it.
type Payment =
| Upi of vpa: string * paise: int64
| Card of last4: string * paise: int64
| Cash of paise: int64
let audit payment =
match payment with
| (Upi(paise = paise) | Card(paise = paise)) as p when paise >= 1_000_000L -> $"check {p}"
| Upi _ | Card _ -> "digital, under 10,000 rupees"
| Cash 0L -> "cash, nothing paid"
| Cash paise -> $"cash, {paise} paise"
for p in [ Upi("asha@okbank", 2_500_000L); Card("4242", 40_000L); Cash 0L; Cash 9_900L ] do
printfn "%s" (audit p)
type Tier =
| Gold of spend: decimal
| Silver of spend: decimal
let discount tier =
match tier with
| Gold spend
| Silver spend when spend > 10000m -> spend * 0.1m
| _ -> 0m
printfn "Gold 5000 gets %M, Silver 15000 gets %M" (discount (Gold 5000m)) (discount (Silver 15000m)) from session import fsi, run
# combine.fsx, then an OR pattern whose two sides bind different names, and an AND pattern.
run("""
dotnet fsi combine.fsx
""")
fsi([
'type Payment =\n | Upi of vpa: string * paise: int64\n | Cash of paise: int64;;',
'let amount p =\n match p with\n | Upi(vpa, paise) | Cash paise -> paise;;',
'let axis point =\n match point with\n | (x, y) & (_, 0) -> $"on the x axis at {x}"\n | _ -> "off the axis";;',
'axis (5, 0), axis (5, 2);;',
'#quit;;',
], args=["--nologo"]) Output
$ dotnet fsi combine.fsx
check Upi ("asha@okbank", 2500000L)
digital, under 10,000 rupees
cash, nothing paid
cash, 9900 paise
Gold 5000 gets 0, Silver 15000 gets 1500.0
$ dotnet fsi --nologo
> type Payment =
- | Upi of vpa: string * paise: int64
- | Cash of paise: int64;;
type Payment =
| Upi of vpa: string * paise: int64
| Cash of paise: int64
> let amount p =
- match p with
- | Upi(vpa, paise) | Cash paise -> paise;;
| Upi(vpa, paise) | Cash paise -> paise;;
------^^^^^^^^^^^^^^^^^^^^^^^^^^^^
stdin(6,7): error FS0018: The two sides of this 'or' pattern bind different sets of variables
> let axis point =
- match point with
- | (x, y) & (_, 0) -> $"on the x axis at {x}"
- | _ -> "off the axis";;
val axis: 'a * int -> string
> axis (5, 0), axis (5, 2);;
val it: string * string = ("on the x axis at 5", "off the axis")
> #quit;;
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 combine_session.py
(Upi(paise = paise) | Card(paise = paise)) as p matched both digital cases, took the amount from the named field
paise of either, and named the whole payment p for the message: “check Upi (“asha@okbank”, 2500000L)“. Every
side of an OR pattern has to introduce exactly the names the others do, each with one type, since the rule’s body may
use them whichever side matched; Upi(vpa, paise) | Cash paise was error FS0018. And (x, y) & (_, 0) bound both
parts while requiring the second to be 0. On its own & rarely beats a single pattern; it earns its place with the
active patterns of the next lesson.
The last line of combine.fsx is a bug that compiled. Gold 5000 got no discount:
The drawing shows the whole scene; its 4 steps are listed below.
Every step as text
- A Gold customer. discount is called with Gold 5000m. The author meant every Gold customer to get 10 %, and only Silver customers to need a spend over 10,000.
- The pattern fits. The left side of the OR pattern, Gold spend, fits and binds spend to 5000.
- The guard belongs to the rule. The guard is part of the whole rule, so it applies to both sides of the OR. spend > 10000m is false for 5000, and the rule does not fit.
- The catch-all answers. The wildcard fits, and the discount is 0: combine.fsx printed Gold 5000 gets 0, while Silver 15000 got 1500.0.
The match expressions reference says it plainly: a guard after an OR pattern applies to all of its patterns, not just the last one. When only one side needs the condition, give that side its own rule.
Checkpoint
Check: as, OR and AND patterns
The compiler checks every match, unless a wildcard hides the gap
Earlier lessons showed FS0025 for a missing rule and FS0026 for a rule that can never run. In real code an
exhaustive match: A match whose rules cover every value its input can have, so it can never raise MatchFailureException. The compiler warns FS0025 when it finds a value no rule covers. usually loses that property later, when a type gains a case.
coverage.fsx adds Wallet to Payment and keeps two matches written before it existed:
// A union with a new case, Wallet, and two matches written before it existed: one lists the cases, one ends with _.
type Payment =
| Upi of vpa: string
| Card of last4: string
| Cash
| Wallet of provider: string
let fee payment =
match payment with
| Upi _ -> 0m
| Card _ -> 1.5m
| Cash -> 0m
let receiptLine payment =
match payment with
| Upi vpa -> $"UPI {vpa}"
| Card last4 -> $"card {last4}"
| _ -> "cash"
printfn "receipt: %s" (receiptLine (Wallet "PayLater"))
try
printfn "fee: %M" (fee (Wallet "PayLater"))
with :? MatchFailureException as e ->
printfn "fee: %s" (e.GetType().Name) from session import fsi, run
# coverage.fsx as a script: the listed match warns about Wallet, the wildcard hides it, and the call that reaches
# the gap fails at run time. Then the same script with FS0025 as an error, and a nested match whose missing value is a pair.
run("""
dotnet fsi coverage.fsx
!dotnet fsi --warnaserror+:25 coverage.fsx
""")
fsi([
'type Payment =\n | Upi of vpa: string\n | Card of last4: string\n | Cash;;',
'let fee (payment, international) =\n match payment, international with\n | Upi _, _ -> 0m\n | Card _, true -> 3.5m\n | Card _, false -> 1.5m;;',
'let fee2 payment =\n match payment with\n | Card _ -> 1.5m\n | _ -> 0m\n | Cash -> 0m;;',
'#quit;;',
], args=["--nologo"]) Output
$ dotnet fsi coverage.fsx
coverage.fsx(9,11): warning FS0025: Incomplete pattern matches on this expression. For example, the value 'Wallet (_)' may indicate a case not covered by the pattern(s).
receipt: cash
fee: MatchFailureException
$ dotnet fsi --warnaserror+:25 coverage.fsx
coverage.fsx(9,11): error FS0025: Incomplete pattern matches on this expression. For example, the value 'Wallet (_)' may indicate a case not covered by the pattern(s).
[exit status 1]
$ dotnet fsi --nologo
> type Payment =
- | Upi of vpa: string
- | Card of last4: string
- | Cash;;
type Payment =
| Upi of vpa: string
| Card of last4: string
| Cash
> let fee (payment, international) =
- match payment, international with
- | Upi _, _ -> 0m
- | Card _, true -> 3.5m
- | Card _, false -> 1.5m;;
match payment, international with
----------^^^^^^^^^^^^^^^^^^^^^^
stdin(6,11): warning FS0025: Incomplete pattern matches on this expression. For example, the value '(Cash,_)' may indicate a case not covered by the pattern(s).
val fee: payment: Payment * international: bool -> decimal
> let fee2 payment =
- match payment with
- | Card _ -> 1.5m
- | _ -> 0m
- | Cash -> 0m;;
| Cash -> 0m;;
------^^^^
stdin(14,7): warning FS0026: This rule will never be matched
val fee2: payment: Payment -> decimal
> #quit;;
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 coverage_session.py
Step through what each match did with the new case:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- Two matches over Payment. fee lists Upi, Card and Cash. receiptLine lists Upi and Card and ends with _, which the author meant for Cash.
- A fourth case. Wallet of provider: string is added to the union. Neither function is changed.
- The compiler reports one. fee now misses a case, so FS0025 names Wallet (_) as its example. receiptLine has no gap: its wildcard covers Wallet, so nothing is reported.
- Both are wrong at run time. The script printed receipt: cash for a wallet payment, a silent wrong answer, and fee raised MatchFailureException.
- Make the warning stop the build. With --warnaserror+:25 the same script stopped with error FS0025 before anything ran. A project gets the same with WarningsAsErrors set to FS0025.
The warning’s example value follows the shape of the match. For the tuple (payment, international) it was (Cash,_):
a pair whose second part may be anything. For fee2 the wildcard came before Cash, and FS0026 said the last rule
will never be matched. The compiler options reference describes --warnaserror+ with a list of warnings, which turned
FS0025 into an error that stopped the script before it ran; a project sets the same with
<WarningsAsErrors>FS0025</WarningsAsErrors>. The three warnings that guard a match, and what each one asks of you:
| Warning | Means | Fix |
|---|---|---|
| FS0025 | a value no rule covers, with an example | a rule for that case |
| FS0026 | a rule that an earlier rule always takes first | move it up, or remove it |
| FS0104 | an enum can hold values with no name | a rule for the rest |
List the cases of your own unions
Use _ for open sets such as numbers and strings, where a default really fits the rest. For a union the program
owns, write a rule per case, grouped with | where answers are shared, so the compiler can find every match a new
case affects.
Checkpoint
Check: Exhaustive matches and their warnings
function, let patterns and type tests cover the other places patterns go
Patterns are not only for match. The function keyword is a lambda that matches its argument at once, so
let sign = function | 0 -> … | … needs no parameter name. A let binding takes a pattern, let (city, pin) = …, and
so does a parameter, as distance (x1, y1) (x2, y2) does. For values that arrive as obj from a .NET API, a
type-test pattern: A pattern written :? T as name that matches when a value's runtime type is T or a type derived from T, for values typed obj or an interface. It converts nothing., :? int as n, checks the runtime type:
// Patterns outside match: the function keyword, let bindings and parameters, and a type-test pattern for values
// that arrive as obj from a .NET API.
let sign =
function
| 0 -> "zero"
| n when n < 0 -> "negative"
| _ -> "positive"
printfn "%s %s %s" (sign 0) (sign -4) (sign 9)
let (city, pin) = ("Pune", "411001")
let distance (x1, y1) (x2, y2) = abs (x2 - x1) + abs (y2 - y1)
printfn "%s %s, distance %d" city pin (distance (0, 0) (3, 4))
let settings: obj list = [ box 30; box "en-IN"; box true; box 2.5; box 30L ]
let describe (value: obj) =
match value with
| :? int as n -> $"int {n}"
| :? string as s -> $"string {s}"
| :? bool as b -> $"bool {b}"
| other -> $"something else: {other.GetType().Name}"
for value in settings do
printfn "%s" (describe value) from session import fsi, run
# places.fsx, then a let pattern that does not cover every list, and a type test on a value whose type is already
# known.
run("""
dotnet fsi places.fsx
""")
fsi([
'let firstTwo (xs: int list) =\n let [ a; b ] = xs\n a + b;;',
'firstTwo [ 1; 2 ];;',
'try string (firstTwo [ 1; 2; 3 ]) with e -> e.GetType().Name;;',
'let n = 42;;',
'match n with :? string as s -> s | _ -> "not a string";;',
'#quit;;',
], args=["--nologo"]) Output
$ dotnet fsi places.fsx
zero negative positive
Pune 411001, distance 7
int 30
string en-IN
bool True
something else: Double
something else: Int64
$ dotnet fsi --nologo
> let firstTwo (xs: int list) =
- let [ a; b ] = xs
- a + b;;
let [ a; b ] = xs
--------^^^^^^^^
stdin(2,9): warning FS0025: Incomplete pattern matches on this expression. For example, the value '[_;_;_]' may indicate a case not covered by the pattern(s).
val firstTwo: xs: int list -> int
> firstTwo [ 1; 2 ];;
val it: int = 3
> try string (firstTwo [ 1; 2; 3 ]) with e -> e.GetType().Name;;
val it: string = "MatchFailureException"
> let n = 42;;
val n: int = 42
> match n with :? string as s -> s | _ -> "not a string";;
match n with :? string as s -> s | _ -> "not a string";;
-------------^^^^^^^^^
stdin(7,14): error FS0016: The type 'int' does not have any proper subtypes and cannot be used as the source of a type test or runtime coercion.
> #quit;;
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 places_session.py
A type test matches the exact type or a type derived from it, with no conversion: box 2.5 and box 30L both reached
the last rule, as Double and Int64, because neither is an int. A type test needs a value whose type could be more
than one thing, so match n with :? string on an int was error FS0016, the type has no proper subtypes.
A let pattern must fit every value it can be given. let [ a; b ] = xs warned FS0025 with [_;_;_] as its example,
worked for a list of two, and raised MatchFailureException for a list of three. Use a let pattern only where one
shape is certain, such as a tuple, and a match everywhere else. The four places a pattern can stand compare like this:
| Where | Example | Every value must fit? |
|---|---|---|
match … with |
match point with … |
no: rules are tried in turn |
function |
let sign = function … |
no: it is a match |
let |
let (city, pin) = … |
yes, or FS0025 |
| a parameter | let distance (x1, y1) (x2, y2) = … |
yes, or FS0025 |
Checkpoint
Check: function, let patterns and type tests
Practise: label a day of transactions without an if
Each transaction is a tuple of an amount in paise, a channel and the hour it happened. label decides with patterns
and guards only, and countRefunds walks the list with cons patterns:
// Practise: label transactions with patterns and guards only, no if expressions. A transaction is (paise, channel,
// hour of the day). Add a rule of your own and run the script again.
type Channel =
| Upi
| Card
| Cash
let label (paise: int64, channel, hour: int) =
match paise, channel, hour with
| p, _, _ when p < 0L -> "refund"
| p, Upi, h when p >= 5_000_000L && (h >= 23 || h < 5) -> "large UPI payment at night"
| p, Cash, _ when p < 50_000L -> "small cash payment"
| _, (Upi | Card), _ -> "digital payment"
| _, Cash, _ -> "cash payment"
let rec countRefunds transactions =
match transactions with
| [] -> 0
| (p, _, _) :: rest when p < 0L -> 1 + countRefunds rest
| _ :: rest -> countRefunds rest
let day =
[ 7_500_000L, Upi, 23; 7_500_000L, Upi, 14; 20_000L, Cash, 10; 90_000L, Cash, 18
-45_000L, Card, 12; 4_999_999L, Upi, 2; 120_000L, Card, 21; -1_000L, Upi, 9 ]
for t in day do
let paise, channel, hour = t
printfn "%10d %-4s %02d:00 %s" paise (sprintf "%A" channel) hour (label t)
printfn "refunds: %d" (countRefunds day) from session import run
run("""
dotnet fsi practice.fsx
""") Output
$ dotnet fsi practice.fsx
7500000 Upi 23:00 large UPI payment at night
7500000 Upi 14:00 digital payment
20000 Cash 10:00 small cash payment
90000 Cash 18:00 cash payment
-45000 Card 12:00 refund
4999999 Upi 02:00 digital payment
120000 Card 21:00 digital payment
-1000 Upi 09:00 refund
refunds: 2
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 practice_session.py
The two 7,500,000-paise Upi payments differ only in their hour, and only the one at 23:00 matched the night rule. The
payment of 4,999,999 at 02:00 is one paisa below the limit, so it is an ordinary digital payment: boundaries such as
>= against > are where tests belong.
Add a rule for large card payments
Add | p, Card, _ when p >= 10_000_000L -> "large card payment" above the rule for digital payments, add
12_000_000L, Card, 15 to day and run dotnet fsi practice.fsx: the new payment is a large card payment. Then
move the rule below the one for digital payments: the script warns FS0026, this rule will never be matched, and the
payment is labelled a digital payment, because _, (Upi | Card), _ takes every card payment first.
How well do the five ideas hold up? Answer a question at a time: the next one comes from your weakest idea so far, and each pick, right or wrong, is explained on the spot:
Practice that adapts to you
Practise this lesson
13 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to 2–5 years): what do the as, OR and AND patterns do in F#? Give an example of each.
An as pattern binds the whole value that a pattern matched to a name, so (Upi _ | Card _) as p gives the matched
payment the name p. An OR pattern, written with |, lets several patterns share one rule; each side has to introduce
the very names the others do, as in Upi(paise = paise) | Card(paise = paise), and F# refuses sides that bind
different names with FS0018. An AND pattern, written with &, needs the value to match both patterns, so
(x, y) & (_, 0) binds x and y and matches only points on the x axis. AND is most useful with active patterns,
which test a value in two ways at once. A guard after an OR pattern applies to every side of it, not only the last.
Two harder questions follow, about a guard that took a discount away and about type tests on values from a .NET library:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A discount rule reads | Gold spend | Silver spend when spend > 10000m -> spend * 0.1m. A Gold customer who spent 5,000 rupees complains that they got no discount; the author meant the limit for Silver only. Explain the bug and rewrite the rules so that the intent is clear and the compiler still checks them.
-
Mid to senior
A settings reader receives values as obj from a .NET library and matches them with :? int, :? string and :? bool, with a fallback rule that uses the default. After a library upgrade, numbers arrive as int64 and every numeric setting quietly takes its default. Diagnose it and design a safer boundary.
Key takeaways
- Patterns nest: one match over a tuple can test union cases, constants and names, with guards where a part is compared with something that is not a literal, and the first rule that fits wins.
- List patterns fit one length, cons patterns any list with a head, and array patterns a fixed length, so fixed lengths
come first and
[]ends a recursion. asnames the whole value, OR patterns share a rule when every side binds the same names,&requires two patterns at once, and a guard after an OR pattern applies to all its sides.- FS0025 names an example value no rule covers and FS0026 a rule that can never run; a wildcard over your own union
hides new cases, and
--warnaserror+:25turns the warning into a stop. function,letbindings and parameters take patterns too, but aletpattern must fit every value, and type tests belong where .NET hands you anobj.
Terms in this lesson
- cons pattern
- A pattern written head :: tail that fits any list with at least one item: head is bound to the first item and tail to the list of the items after it, which may be empty.
- exhaustive match
- A match whose rules cover every value its input can have, so it can never raise MatchFailureException. The compiler warns FS0025 when it finds a value no rule covers.
- OR pattern
- Two or more patterns joined by |, such as Upi _ | Card _, that share one rule. Every side must bind the same names with the same types, and a guard after it applies to all sides.
- type-test pattern
- A pattern written :? T as name that matches when a value's runtime type is T or a type derived from T, for values typed obj or an interface. It converts nothing.
References
- Pattern matching (Microsoft Learn)
- Match expressions (Microsoft Learn)
- Compiler options (--warnaserror) (Microsoft Learn)
- F# compiler messages (Microsoft Learn)
- Lambda expressions: the fun keyword (Microsoft Learn)
- Casting and conversions (type tests) (Microsoft Learn)
Report a problem with this lesson
Kept only in this browser. Your Learn progress