Why Active Patterns Exist
Ordinary pattern matching in F# works directly against the shape of a value — the cases of a discriminated union, the fields of a tuple, a literal. Active patterns let you extend this same match syntax to values whose 'shape' isn't structurally obvious, by defining a function whose job is to classify or decompose the value into named cases. Instead of writing if isEven n then ... else ... and having to remember what isEven means at every call site, an active pattern like (|Even|Odd|) lets you write match n with | Even -> ... | Odd -> ..., so the classification logic is written once and the call site reads exactly like matching against a real discriminated union case, even though int has no such cases built in.
Cricket analogy: A scorer who invents shorthand categories like 'boundary' or 'dot ball' to describe deliveries, even though the rulebook only defines 'runs scored,' is creating a custom classification the way an active pattern like (|Even|Odd|) creates named cases for a type that has none built in.
Single-Case and Total Active Patterns
A total active pattern covers every possible input and returns one of a fixed set of named cases — let (|Even|Odd|) n = if n % 2 = 0 then Even else Odd is total because every integer is either Even or Odd, with nothing left over. A single-case active pattern, written (|Name|), is often used not for classification but for decomposition or transformation: let (|Rectangle|) (w, h) = (w, h, w * h) lets you pattern-match a tuple while simultaneously computing a derived value, so matching against Rectangle (w, h, area) extracts width, height, and the computed area in one step, right inside the match arm.
Cricket analogy: A pitch report that always classifies conditions as either 'seaming' or 'spinning,' with no third option ever possible, mirrors how (|Even|Odd|) is total — every input falls into exactly one of the defined cases.
// Total active pattern: covers every possible int
let (|Even|Odd|) n = if n % 2 = 0 then Even else Odd
let describe n =
match n with
| Even -> sprintf "%d is even" n
| Odd -> sprintf "%d is odd" n
// Single-case active pattern: decomposes/transforms while matching
let (|Rectangle|) (w, h) = (w, h, w * h)
match (3, 4) with
| Rectangle (w, h, area) -> printfn "%dx%d = %d" w h area // "3x4 = 12"
// Partial active pattern: may or may not match, returns an option
let (|Vowel|_|) (c: char) =
if "aeiouAEIOU".Contains(c) then Some c else None
let classify c =
match c with
| Vowel v -> sprintf "%c is a vowel" v
| _ -> sprintf "%c is a consonant" c
// Parameterized partial active pattern
let (|DivisibleBy|_|) divisor n =
if n % divisor = 0 then Some () else None
let fizzbuzz n =
match n with
| DivisibleBy 15 () -> "FizzBuzz"
| DivisibleBy 3 () -> "Fizz"
| DivisibleBy 5 () -> "Buzz"
| _ -> string nPartial Active Patterns
Not every classification can be total — matching depends on runtime input like a regex or an external lookup, where 'no match' is a legitimate outcome rather than a bug. A partial active pattern is written (|Name|_|) and returns an option: let (|Vowel|_|) (c: char) = if "aeiouAEIOU".Contains(c) then Some c else None. Inside a match, | Vowel v -> ... only fires when the pattern returns Some, and F# requires a wildcard | _ -> ... arm afterward because the compiler can't prove a partial pattern is exhaustive. This is the mechanism behind libraries that let you pattern-match HTTP requests, regex captures, or JSON shapes as if they were ordinary discriminated union cases.
Cricket analogy: A DRS 'inconclusive' verdict, where the technology sometimes simply can't determine an outcome and play reverts to the on-field call, mirrors how a partial active pattern returns None when there's no clean match, requiring a fallback case.
Parameterized Active Patterns
Active patterns can also take extra arguments before the value being matched, letting you parameterize the classification itself. let (|DivisibleBy|_|) divisor n = if n % divisor = 0 then Some () else None takes divisor as a normal parameter and n as the value being matched; inside a match, you supply the extra argument directly in the pattern: | DivisibleBy 3 () -> "Fizz". This is exactly how a classic FizzBuzz can be written entirely through pattern matching, with DivisibleBy 15, DivisibleBy 3, and DivisibleBy 5 tried in order as ordinary match arms, reading far more declaratively than a chain of if/elif checks against n % 3 = 0 and n % 5 = 0.
Cricket analogy: A custom stat query like 'wickets taken against left-handed batsmen' takes a parameter (left-handed) before applying the classification to a bowler's record, mirrors how DivisibleBy takes divisor as a parameter before matching against n.
Active patterns are, under the hood, ordinary functions — (|Even|Odd|) compiles down to a function returning a special Choice-like type, and (|Vowel|_|) compiles down to a function returning option. This means they're called fresh on every match attempt with no caching, so an active pattern that does expensive work (a database lookup, a regex compile) inside its body will redo that work every time it's evaluated in a match arm.
F# limits total active patterns like (|A|B|C|...|) to a maximum of 7 named cases; beyond that, you'll need to restructure the classification (often into a real discriminated union) rather than push more cases into a single active pattern. Also, chaining many active patterns into one large match can obscure control flow just as badly as a wall of if/elif, so use them for genuinely reusable classifications, not as a one-off replacement for a simple boolean check.
- Active patterns extend F#'s match syntax to any type by defining custom functions that classify or decompose values into named cases.
- A total active pattern, written (|A|B|), covers every possible input; a single-case pattern (|Name|) is often used for decomposition or transformation.
- A partial active pattern, written (|Name|_|), returns an option and is used when 'no match' is a legitimate outcome.
- Match expressions using partial active patterns require a wildcard | _ -> arm because the compiler can't prove exhaustiveness.
- Parameterized active patterns take extra arguments before the matched value, letting you customize the classification per use.
- Active patterns are ordinary functions under the hood, re-evaluated on every match attempt with no automatic caching.
- Total active patterns are capped at 7 named cases; beyond that, prefer a real discriminated union.
Practice what you learned
1. What is the key difference between (|Even|Odd|) and (|Vowel|_|)?
2. Why does a match expression using a partial active pattern require a wildcard | _ -> arm?
3. What does a single-case active pattern like let (|Rectangle|) (w, h) = (w, h, w * h) typically enable?
4. How does let (|DivisibleBy|_|) divisor n = ... differ from a plain partial active pattern?
5. What is the maximum number of named cases allowed in a total active pattern like (|A|B|C|...|)?
Was this page helpful?
You May Also Like
Higher-Order Functions in F#
Learn how F# treats functions as first-class values that can be passed as arguments, returned as results, and composed to build powerful, reusable data transformations.
Pipelining and Composition
Master F#'s |> pipe operator and >> / << composition operators to chain transformations into clear, left-to-right data pipelines.
Computation Expressions
See how F#'s computation expressions, like the built-in option, async, and seq builders, plus custom ones, let you write sequential-looking code over effects like optionality, asynchrony, or laziness.
Related Reading
Related Study Notes in Programming
Browse all study notesApache Spark Study Notes
Programming · 30 topics
ProgrammingApache Flink Study Notes
Programming · 30 topics
ProgrammingHadoop Study Notes
Programming · 30 topics
ProgrammingSnowflake Study Notes
Programming · 30 topics
ProgrammingApache Airflow Study Notes
Programming · 30 topics
Programmingdbt (Data Build Tool) Study Notes
Programming · 30 topics