You are viewing a preview of this lesson. Sign in to start learning
Back to Functional Programming with F#

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

  1. Microsoft F# Language Reference - Pattern Matching: https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/pattern-matching
  2. F# for Fun and Profit - Match Expressions: https://fsharpforfunandprofit.com/posts/match-expression/
  3. 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!

Practice Questions

Test your understanding with these questions:

Q1: What is the wildcard pattern character in F# that matches any value? ```fsharp match x with | 0 -> "zero" | {{1}} -> "other" ```
A: _
Q2: Complete the list pattern to match a list with exactly one element: ```fsharp match myList with | [] -> "empty" | [{{1}}] -> "one item" | _ -> "multiple" ```
A: x
Q3: What does this F# pattern matching code return? ```fsharp let check n = match n with | x when x < 0 -> "negative" | 0 -> "zero" | _ -> "positive" check -5 ``` A. "zero" B. "positive" C. "negative" D. Error: non-exhaustive E. None
A: C
Q4: Fill in the keyword used to add conditional logic to a pattern: ```fsharp match age with | n {{1}} n < 18 -> "minor" | _ -> "adult" ```
A: ["when"]
Q5: What happens when you compile this F# code? ```fsharp type Color = Red | Green | Blue let describe c = match c with | Red -> "red" | Green -> "green" // Missing Blue case ``` A. Compiles fine, returns empty string for Blue B. Runtime error when passed Blue C. Compiler warning about incomplete pattern D. Compiler error, won't compile E. Returns "Blue" automatically
A: C