Modeling Payment Methods with F# Discriminated Unions and Exhaustive Pattern Matching
Learn how F# discriminated unions eliminate illegal states and how pattern matching gives you compile‑time exhaustiveness checks for safer domain modeling.
01 May 2026, 17:30 UTC

Problem: Boolean flags hide illegal states
When modeling something like a payment method, a quick approach is to use a few Boolean flags or nullable properties: IsCreditCard, CardNumber, IsPayPal, PayPalEmail. This representation allows values that cannot exist in reality, such as both IsCreditCard = true and IsPayPal = true being set at the same time. Those illegal states make the code harder to reason about and increase the chance of bugs.
Thesis: Discriminated Unions + pattern matching make illegal states unrepresentable and give compile‑time exhaustiveness checks
A discriminated union (DU) defines a type that can be exactly one of a fixed set of cases. Each case can carry its own data, so the type itself encodes the legal states of the domain. When you consume a DU with a match expression, the F# compiler checks that every case is handled and warns you if any are missing. This gives you two guarantees: you cannot accidentally create an illegal state, and you cannot forget to handle a new state when the domain evolves.
Section 1: From flags to a discriminated union
Consider a simple payment method that can be either a credit card or a PayPal account. Using flags you might write:
type PaymentMethodFlag = {
IsCreditCard : bool
CardNumber : string option
IsPayPal : bool
PayPalEmail : string option
}
This type lets you create a value where both IsCreditCard = true and IsPayPal = true – a combination that has no meaning.
Replace it with a DU:
type PaymentMethod =
| CreditCard of CardNumber: string
| PayPal of Email: string
Now a value is either a CreditCard with a card number or a PayPal with an email address. The compiler prevents the impossible combination.
Section 2: Pattern matching and exhaustiveness
To work with a PaymentMethod you write a match expression:
let display (pm: PaymentMethod) : string =
match pm with
| CreditCard cn ->
let lastFour = if cn.Length >= 4 then cn.Substring(cn.Length - 4) else cn
sprintf "Credit card ending in %s" lastFour
| PayPal em -> sprintf "PayPal account: %s" em
If you accidentally omit a case, the compiler emits warning FS0025 (incomplete pattern matches). You can promote this warning to an error so that the build fails when a case is missed.
Worked example: verifying the exhaustiveness warning
- Create a new F# console project:
dotnet new console -lang F# -n PaymentDemo
cd PaymentDemo
- Replace the generated
Program.fswith the following code (note the missing PayPal case on purpose):
type PaymentMethod =
| CreditCard of CardNumber: string
| PayPal of Email: string
let display (pm: PaymentMethod) : string =
match pm with
| CreditCard cn ->
let lastFour = if cn.Length >= 4 then cn.Substring(cn.Length - 4) else cn
sprintf "Credit card ending in %s" lastFour
// PayPal case intentionally omitted
[]
let main argv =
let pm = CreditCard "1234567812345678"
printfn "%s" (display pm)
0
- Edit the project file to treat warning FS0025 as an error:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<WarningsAsErrors>25</WarningsAsErrors>
</PropertyGroup>
</Project>
- Build the project:
dotnet build
The build fails with output similar to:
Program.fs(10,5): error FS0025: Incomplete pattern matches on this expression. For example, the value 'PayPal ""' could indicate a case not covered.
Adding the missing branch removes the error:
let display (pm: PaymentMethod) : string =
match pm with
| CreditCard cn ->
let lastFour = if cn.Length >= 4 then cn.Substring(cn.Length - 4) else cn
sprintf "Credit card ending in %s" lastFour
| PayPal em -> sprintf "PayPal account: %s" em
Trade‑off: maintaining matches when the DU evolves
One practical limitation is that adding a new union case requires you to visit every match expression that handles the type and add a branch for the new case. The compiler will warn (or error, if you enabled WarningsAsErrors) if you forget, but large codebases can still feel the “pyramid of doom” if matches are deeply nested.
Mitigation strategies:
- Break complex matches into small, private functions that each handle a single concern.
- Use active patterns to encapsulate recurring guard logic, keeping the main match flat.
- Enable
<WarningsAsErrors>25</WarningsAsErrors>in your project so that any missing case fails the build immediately.
Actionable closing: start refactoring today
Identify a domain model in your codebase that relies on multiple Boolean flags or nullable fields to represent state. Replace it with a discriminated union that captures each legal state as a distinct case. Write a match expression to consume the DU, and compile with warnings treated as errors to guarantee you handle every case. Over time you’ll see fewer illegal‑state bugs and clearer, more self‑documenting code.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.