Eliminating Invalid States in F# with Discriminated Unions
Stop using nulls and booleans to track state. Learn how F# Discriminated Unions make illegal program states unrepresentable through exhaustive pattern matching.
31 Oct 2025, 21:47 UTC

The Problem: The "Impossible" State
In many applications, we track the state of a process using a combination of booleans, nullable strings, and enums. Consider a payment processing system where you have a Payment record with IsPaid: bool, PaymentDate: DateTime?, and ErrorMessage: string.
This design creates a state space where it is possible to have IsPaid = true but PaymentDate = null, or IsPaid = false but ErrorMessage = null. These are "illegal states"—configurations of data that should not exist logically but are permitted by the type system. Detecting these requires defensive null checks and complex if/else logic scattered across the codebase.
The Solution: Discriminated Unions
A Discriminated Union (DU) allows you to define a type that can be one of several different forms, where each form (or "case") carries its own specific data. Instead of a single record with optional fields, you define a type that represents the exact current state of the entity.
By using DUs, you move the validation from runtime checks to the compiler. If a payment is in the Paid state, it must have a date. If it is Failed, it must have an error message. It is physically impossible to represent a "Paid" payment without a date.
Implementing Domain States
In F#, DUs are defined using the pipe (|) symbol. Each case can be a simple label or a constructor that holds values.
type PaymentStatus =
| Pending
| Paid of decimal * System.DateTime
| Failed of stringThe power of this approach is realized through Pattern Matching. The F# compiler performs exhaustive checking, meaning if you add a new case to PaymentStatus but forget to handle it in a function, the compiler will issue a warning.
Worked Example: Handling Payment Logic
Run the following code in an F# Interactive (FSI) session to see how the compiler enforces state handling. This example assumes F# 6.0 or later.
let processPayment status =
match status with
| Pending -> "Payment is awaiting confirmation."
| Paid (amount, date) -> sprintf "Paid %.2f on %s" amount (date.ToShortDateString())
| Failed reason -> sprintf "Payment failed: %s" reason
// Test cases
let state1 = Pending
let state2 = Paid (99.99m, System.DateTime.Now)
let state3 = Failed "Insufficient funds"
printfn "%s" (processPayment state1)
printfn "%s" (processPayment state2)
printfn "%s" (processPayment state3)Verification: To see the compiler's safety mechanism, remove the | Failed reason line from the match expression. The compiler will generate a warning: "Incomplete pattern matches on this expression. For example, the value 'Failed' may indicate a case not covered by the pattern(s)."
Trade-offs and Limitations
While DUs simplify domain modeling, they introduce specific engineering challenges:
- C# Interoperability: C# does not have native Discriminated Unions. When an F# library exposes a DU to C#, the F# compiler generates a class hierarchy. C# consumers must use pattern matching (available in newer C# versions) or type casting, which is less ergonomic than the native F# experience.
- Breaking Changes: Adding a new case to a DU in a shared library is a breaking change. Because the compiler enforces exhaustive matching, every downstream consumer's code will suddenly produce warnings or fail to compile until the new case is handled.
- Nesting Complexity: Deeply nested DUs (a DU containing another DU) can lead to "staircase" indentation in pattern matching. In these cases, it is better to break the matching logic into smaller, specialized functions.
Practical Result Check
To verify if your domain model is effectively using DUs, look for if statements that check for null or boolean flags to determine which fields are safe to access. If you find a pattern like if (order.IsShipped && order.TrackingNumber != null), that logic is a candidate for a Discriminated Union: | Shipped of TrackingNumber.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.