Modeling Finite States with F# Discriminated Unions and Exhaustive Pattern Matching
Learn how F# discriminated unions with exhaustive pattern matching make illegal states unrepresentable and give compiler warnings when you forget to handle a new state.
05 Aug 2025, 13:52 UTC

Problem: Boolean flags and nullable strings hide invalid states
When a workflow is modeled with a few Boolean fields or nullable strings, it is easy to reach combinations that make no sense—for example, an order that is both "Paid" and "Cancelled" or a status string that contains a typo. Those invalid states compile fine and only surface at runtime, often as confusing bugs.
Solution: Define a discriminated union for the domain
F# discriminated unions let you give each valid state its own case and attach only the data that belongs to that case. The type below models a simple order lifecycle.
type OrderState =
| Created
| Paid of PaymentId
| Shipped of TrackingNumber
| Cancelled of Reason
Each case carries exactly the information that makes sense: Paid holds a payment identifier, Shipped a tracking number, and Cancelled a reason. No case can accidentally hold both a payment and a tracking number.
Exhaustive pattern matching gives compiler help
Processing the state with a match expression forces you to handle every case. If you forget one, the compiler emits a warning before the program runs.
let processState (state: OrderState) =
match state with
| Created -> printfn "Order is new"
| Paid id -> printfn "Paid %s" id
| Shipped tracking -> printfn "Shipped %s" tracking
| Cancelled reason -> printfn "Cancelled: %s" reason
Add a new case, for example Refunded of RefundId, rebuild the project and you will see a warning that the match is incomplete. This turns a runtime omission into a compile‑time signal, guiding you to the exact place where the new decision point belongs.
Trade‑off: closed set vs extensibility
Discriminated unions shine when the set of alternatives is known and fixed, like the stages of a checkout flow. If you expect third‑party plugins to add new states at runtime, an interface‑based design or object expressions may be more appropriate because DUs require recompiling the defining module to add a case.
Getting started
- Run
dotnet new console -lang F# -o OrderDemoin a terminal with the .NET SDK installed. - Change directory:
cd OrderDemo. - Replace the generated
Program.fswith the DU definition and theprocessStatefunction shown above. - Build the project:
dotnet build. The first build should succeed with no warnings. - Add a new case, e.g.
| Refunded of RefundIdto theOrderStatetype, keep the existing match expression unchanged, and rebuild. - Observe the compiler warning similar to "This match rule is incomplete. Cases ... are not covered." – this indicates the missing handling.
No special permissions are required beyond the ability to create and build a .NET project on your machine. The only risk is the usual one of introducing a breaking change if the DU is part of a public API; in that case you should version the contract or provide a migration path.
Limitations and verification
While DUs make illegal states unrepresentable in memory, they do not dictate how the data is serialized. When you send an OrderState value over HTTP or store it in a database, you must choose a representation (for example, a discriminated union JSON format) and version it separately. To verify the behavior, compile the sample, add a case, and confirm that the warning appears before running the executable.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.