Pattern Matching
Leverage exhaustive pattern matching for safe control flow, data decomposition, and type-driven development with compile-time guarantees.
Pattern Matching in F#
Master pattern matching in F# with free flashcards and spaced repetition practice. This lesson covers match expressions, discriminated unions, record patterns, and advanced matching techniques—essential concepts for writing concise, type-safe functional code in F#.
Welcome to Pattern Matching 💻
Pattern matching is one of F#'s most powerful features, allowing you to decompose data structures and make decisions based on their shape and content. Unlike traditional switch statements in imperative languages, F# pattern matching is exhaustive (the compiler checks you've covered all cases), type-safe, and incredibly expressive.
Think of pattern matching as a sophisticated sorting machine 📦: you put an item in, and it automatically routes to the correct handler based on what it looks like—not just its value, but its entire structure.
Core Concepts
Basic Match Expressions 🎯
The match expression is the foundation of pattern matching in F#. It evaluates an expression and compares it against a series of patterns, executing the code for the first matching pattern.
Syntax:
match expression with
| pattern1 -> result1
| pattern2 -> result2
| _ -> defaultResult
The _ (underscore) is the wildcard pattern that matches anything—like a catch-all basket at the end of your sorting machine.
Simple example:
let describeNumber n =
match n with
| 0 -> "zero"
| 1 -> "one"
| 2 -> "two"
| _ -> "many"
let result = describeNumber 5 // "many"
💡 Tip: The F# compiler warns you if your patterns aren't exhaustive. This prevents runtime errors!
Constant Patterns 🔢
Constant patterns match literal values. You can match numbers, strings, booleans, and other primitive types:
let greet language =
match language with
| "English" -> "Hello"
| "Spanish" -> "Hola"
| "French" -> "Bonjour"
| "German" -> "Guten Tag"
| _ -> "Hi"
Guard Clauses (when) ⚡
Guard clauses add additional conditions to patterns using the when keyword:
let categorizeAge age =
match age with
| n when n < 0 -> "Invalid"
| n when n < 13 -> "Child"
| n when n < 20 -> "Teenager"
| n when n < 65 -> "Adult"
| _ -> "Senior"
🧠 Memory Device: Think of "when" as adding a security checkpoint after the pattern—it must pass both the pattern match AND the guard.
Discriminated Unions and Pattern Matching 🎭
Discriminated unions (DUs) are custom types that can hold different cases, and they shine with pattern matching:
type Shape =
| Circle of radius: float
| Rectangle of width: float * height: float
| Triangle of base_: float * height: float
let calculateArea shape =
match shape with
| Circle radius -> 3.14159 * radius * radius
| Rectangle (width, height) -> width * height
| Triangle (base_, height) -> 0.5 * base_ * height
let myCircle = Circle 5.0
let area = calculateArea myCircle // 78.53975
Why this matters: The compiler ensures you handle ALL cases. If you add a new shape later, the compiler will tell you everywhere you need to update your match expressions!
Tuple Patterns 📦📦
Tuple patterns destructure tuples directly in the match:
let describePoint point =
match point with
| (0, 0) -> "Origin"
| (x, 0) -> sprintf "On X-axis at %d" x
| (0, y) -> sprintf "On Y-axis at %d" y
| (x, y) when x = y -> sprintf "On diagonal at (%d, %d)" x y
| (x, y) -> sprintf "At (%d, %d)" x y
List Patterns 📝
List patterns use the :: (cons) operator to match list structure:
let rec describeList lst =
match lst with
| [] -> "Empty list"
| [single] -> sprintf "Single element: %d" single
| [first; second] -> sprintf "Two elements: %d and %d" first second
| head :: tail -> sprintf "Starts with %d, has %d more" head (List.length tail)
let result = describeList [1; 2; 3; 4] // "Starts with 1, has 3 more"
🔧 Try this: The head :: tail pattern is fundamental for recursive list processing. The head is the first element, tail is the rest.
Record Patterns 🗂️
Record patterns destructure record types:
type Person = { Name: string; Age: int; City: string }
let describePerson person =
match person with
| { Name = "Alice"; Age = age } -> sprintf "Alice is %d years old" age
| { Age = age; City = "Seattle" } when age < 30 -> "Young Seattleite"
| { Name = name; Age = age } when age >= 65 -> sprintf "%s is a senior" name
| { Name = name } -> sprintf "Just met %s" name
💡 Tip: You don't need to match all fields—only the ones you care about!
Active Patterns 🎨
F#'s active patterns let you create custom matching logic. There are three types:
1. Single-case active patterns (Total):
let (|Even|Odd|) n =
if n % 2 = 0 then Even else Odd
let testNumber n =
match n with
| Even -> sprintf "%d is even" n
| Odd -> sprintf "%d is odd" n
2. Multi-case active patterns:
let (|Positive|Zero|Negative|) n =
if n > 0 then Positive
elif n = 0 then Zero
else Negative
let describeSign n =
match n with
| Positive -> "Positive number"
| Zero -> "Zero"
| Negative -> "Negative number"
3. Partial active patterns:
let (|DivisibleBy|_|) divisor n =
if n % divisor = 0 then Some DivisibleBy else None
let checkNumber n =
match n with
| DivisibleBy 3 & DivisibleBy 5 -> "FizzBuzz"
| DivisibleBy 3 -> "Fizz"
| DivisibleBy 5 -> "Buzz"
| _ -> string n
🤔 Did you know? Active patterns are one of F#'s unique features that other functional languages envy. They let you extend pattern matching with domain-specific logic!
Option Patterns 🎁
Option types represent values that might be absent, and pattern matching handles them elegantly:
let displayResult maybeValue =
match maybeValue with
| Some value -> sprintf "Got: %d" value
| None -> "No value"
let result1 = displayResult (Some 42) // "Got: 42"
let result2 = displayResult None // "No value"
Nested Patterns 🪆
Patterns can be nested to match complex structures:
type Address = { Street: string; City: string }
type Customer = { Name: string; Address: Address option }
let getCity customer =
match customer with
| { Address = Some { City = city } } -> city
| { Address = None } -> "Unknown"
let customer = { Name = "Bob"; Address = Some { Street = "123 Main"; City = "Portland" } }
let city = getCity customer // "Portland"
As Patterns 📌
The as pattern lets you capture both the whole value and its parts:
let describeList lst =
match lst with
| [] -> "Empty"
| [_] -> "One element"
| head :: _ as fullList -> sprintf "Starts with %d, full list has %d items" head (List.length fullList)
OR Patterns (|) 🔀
OR patterns match multiple patterns with the same result:
let isWeekend day =
match day with
| "Saturday" | "Sunday" -> true
| _ -> false
let isPrimaryColor color =
match color with
| "red" | "blue" | "yellow" -> true
| _ -> false
AND Patterns (&) 🔗
AND patterns require multiple patterns to match simultaneously:
let (|Between|_|) min max value =
if value >= min && value <= max then Some Between else None
let categorizeScore score =
match score with
| Between 90 100 & n -> sprintf "%d is an A" n
| Between 80 89 & n -> sprintf "%d is a B" n
| _ -> "Below B"
Example 1: Processing User Input 🎮
type Command =
| Move of direction: string * distance: int
| Attack of target: string
| Heal
| Quit
let executeCommand cmd =
match cmd with
| Move ("north", dist) when dist > 0 -> sprintf "Moving north %d steps" dist
| Move (dir, dist) when dist > 0 -> sprintf "Moving %s %d steps" dir dist
| Move _ -> "Invalid move: distance must be positive"
| Attack target -> sprintf "Attacking %s!" target
| Heal -> "Healing player..."
| Quit -> "Goodbye!"
let cmd1 = Move ("north", 5)
let result = executeCommand cmd1 // "Moving north 5 steps"
Why this works: The discriminated union provides type safety, ensuring commands always have the correct data. Pattern matching with guards validates the distance, and specific pattern ordering handles special cases (north) before general ones.
Example 2: JSON-like Data Structure 🌐
type Json =
| JNull
| JBool of bool
| JNumber of float
| JString of string
| JArray of Json list
| JObject of (string * Json) list
let rec prettyPrint json =
match json with
| JNull -> "null"
| JBool b -> string b
| JNumber n -> string n
| JString s -> sprintf "\"%s\"" s
| JArray items ->
let content = items |> List.map prettyPrint |> String.concat ", "
sprintf "[%s]" content
| JObject props ->
let formatProp (key, value) = sprintf "\"%s\": %s" key (prettyPrint value)
let content = props |> List.map formatProp |> String.concat ", "
sprintf "{%s}" content
let myJson = JObject [
"name", JString "Alice"
"age", JNumber 30.0
"active", JBool true
]
let output = prettyPrint myJson
// "{"name": "Alice", "age": 30, "active": true}"
Why this works: Recursive pattern matching handles nested structures naturally. Each case handles one data type, and the JArray/JObject cases recursively process their contents.
Example 3: Binary Tree Operations 🌳
type BinaryTree<'T> =
| Empty
| Node of value: 'T * left: BinaryTree<'T> * right: BinaryTree<'T>
let rec contains item tree =
match tree with
| Empty -> false
| Node (value, left, right) when value = item -> true
| Node (value, left, right) when item < value -> contains item left
| Node (_, _, right) -> contains item right
let rec insert item tree =
match tree with
| Empty -> Node (item, Empty, Empty)
| Node (value, left, right) when item < value ->
Node (value, insert item left, right)
| Node (value, left, right) when item > value ->
Node (value, left, insert item right)
| _ -> tree // item already exists
let myTree = Empty
let tree1 = insert 5 myTree
let tree2 = insert 3 tree1
let tree3 = insert 7 tree2
let found = contains 3 tree3 // true
Why this works: Pattern matching on recursive data structures (like trees) is incredibly elegant in F#. The Empty case provides the base case, while the Node pattern destructures the tree for recursive traversal.
Example 4: Result Type Error Handling ⚠️
type Result<'T, 'E> =
| Ok of 'T
| Error of 'E
let divide x y =
match y with
| 0 -> Error "Cannot divide by zero"
| _ -> Ok (x / y)
let processResult result =
match result with
| Ok value -> sprintf "Success: %d" value
| Error msg -> sprintf "Error: %s" msg
let result1 = divide 10 2 |> processResult // "Success: 5"
let result2 = divide 10 0 |> processResult // "Error: Cannot divide by zero"
// Chaining results
let (>>=) result fn =
match result with
| Ok value -> fn value
| Error e -> Error e
let calculate =
divide 100 5
>>= (fun x -> divide x 2)
>>= (fun x -> divide x 2)
// calculate = Ok 5
Why this works: The Result type makes error handling explicit and type-safe. Pattern matching extracts values from Ok cases or handles errors from Error cases, preventing null reference exceptions.
Common Mistakes ⚠️
1. Non-Exhaustive Patterns
❌ Wrong:
let describeOption opt =
match opt with
| Some value -> sprintf "Has: %d" value
// Missing None case - compiler warning!
✅ Right:
let describeOption opt =
match opt with
| Some value -> sprintf "Has: %d" value
| None -> "Nothing"
2. Unreachable Patterns
❌ Wrong:
let check n =
match n with
| _ -> "anything" // This catches everything!
| 0 -> "zero" // Unreachable - never executes
✅ Right:
let check n =
match n with
| 0 -> "zero"
| _ -> "anything"
Pattern order matters! More specific patterns must come before general ones.
3. Forgetting Parentheses in Tuple Patterns
❌ Wrong:
let describe x, y = // This is TWO parameters, not a tuple!
match x with
| 0, 0 -> "origin" // Compile error
✅ Right:
let describe (x, y) = // Single tuple parameter
match (x, y) with
| (0, 0) -> "origin"
| _ -> "somewhere else"
4. Misusing Guards Instead of Patterns
❌ Wrong (less efficient):
let check opt =
match opt with
| x when x = Some 0 -> "zero"
| x when x = None -> "nothing"
✅ Right (direct pattern):
let check opt =
match opt with
| Some 0 -> "zero"
| None -> "nothing"
| Some _ -> "something"
5. Ignoring Compiler Warnings
The F# compiler's pattern matching warnings are your friends! They prevent runtime bugs:
- Warning FS0025: Incomplete pattern matches
- Warning FS0026: Unreachable code in pattern matching
Always address these warnings!
Key Takeaways 🎯
✨ Pattern matching is F#'s primary way to decompose data and make decisions
✨ The compiler checks exhaustiveness, preventing missing cases
✨ Discriminated unions + pattern matching = type-safe, maintainable code
✨ Guard clauses (when) add conditions beyond structure matching
✨ Active patterns extend matching with custom logic
✨ Pattern order matters—specific before general
✨ Use tuple, list, and record patterns to destructure complex data
✨ Nested patterns handle complex structures elegantly
✨ OR (|) and AND (&) patterns combine multiple conditions
📋 Quick Reference Card
| Pattern Type | Syntax | Example |
|---|---|---|
| Constant | | 42 -> |
Match specific value |
| Wildcard | | _ -> |
Match anything |
| Variable | | x -> |
Capture value as x |
| Tuple | | (x, y) -> |
Destructure tuple |
| List Empty | | [] -> |
Match empty list |
| List Cons | | head::tail -> |
Split first from rest |
| Record | | { Name = n } -> |
Extract record fields |
| Option Some | | Some x -> |
Extract optional value |
| Option None | | None -> |
Handle missing value |
| Guard | | x when x > 0 -> |
Add condition |
| OR | | 1 | 2 | 3 -> |
Multiple patterns |
| AND | | x & y -> |
Both must match |
| As | | h::t as full -> |
Capture whole + parts |
| Active | | Even -> |
Custom pattern logic |
📚 Further Study
- Microsoft F# Language Reference - Pattern Matching: https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/pattern-matching
- F# for Fun and Profit - Match Expressions: https://fsharpforfunandprofit.com/posts/match-expression/
- F# Software Foundation - Active Patterns: https://fsharp.org/specs/language-spec/4.1/FSharpSpec-4.1-latest.pdf
💡 Practice tip: Start by pattern matching on simple types, then gradually work up to discriminated unions and active patterns. The more you use pattern matching, the more you'll appreciate how it eliminates entire classes of bugs!