Eliminating Illegal States in F# with Discriminated Unions
Stop relying on boolean flags and nullable fields. Learn how F# Discriminated Unions make illegal domain states unrepresentable and leverage compile-time exhaustiveness checks.
22 Mar 2026, 13:50 UTC

The Problem: The 'Boolean Soup' of State Management
In many object-oriented systems, domain states are managed using a combination of nullable fields and boolean flags. Consider a typical order processing system where an order can be IsPending, IsShipped, or IsCancelled. To track this, developers often create multiple boolean properties or a single enum paired with optional metadata fields (like a ShippingDate that is null unless the state is 'Shipped').
The risk here is the "illegal state." Nothing prevents a developer from accidentally setting IsPending = true and IsShipped = true simultaneously, or marking an order as 'Cancelled' while still providing a shipping date. These contradictions lead to runtime exceptions and fragile if/else chains that are difficult to test and maintain.
The Thesis: Making Illegal States Unrepresentable
The most effective way to handle complex domain states is to move the validation from runtime logic into the type system. In F#, Discriminated Unions (DUs) allow you to define a type that can be exactly one of several distinct cases, where each case carries only the data relevant to that specific state. By using DUs, you ensure that it is physically impossible to represent an order that is both pending and shipped.
Modeling Domain Workflows
A Discriminated Union is a type that can hold different shapes of data depending on which "case" is active. Unlike a standard Enum, which is just a named integer, each case in a DU can be a record or a tuple of values.
When you combine DUs with Pattern Matching, the F# compiler performs an exhaustiveness check. If you add a new state to your union but forget to handle it in a specific business logic function, the compiler will issue a warning. This transforms a potential production crash into a compile-time checklist.
Worked Example: Order Lifecycle Management
Below is a comparison of how to model an order state. This example assumes F# 6.0+ and targets .NET 6/7/8.
// Define the domain states
type OrderState =
| Pending of CustomerId: int
| Shipped of TrackingId: string * ShipDate: System.DateTime
| Delivered of DeliveryDate: System.DateTime
| Cancelled of Reason: string
// A function to generate a customer-facing status message
let getStatusMessage state =
match state with
| Pending cid -> sprintf "Order for customer %d is awaiting processing." cid
| Shipped (tid, date) -> sprintf "Shipped on %s. Tracking: %s" (date.ToShortDateString()) tid
| Delivered date -> sprintf "Delivered on %s." (date.ToShortDateString())
| Cancelled reason -> sprintf "Order cancelled: %s" reason
// Usage
let myOrder = Shipped("ABC-123", System.DateTime.Now)
printfn "%s" (getStatusMessage myOrder)
Verification and Diagnostic Check
To verify the safety of this pattern, try removing the Cancelled case from the match expression in the getStatusMessage function. The F# compiler will generate warning FS0025: "Incomplete pattern matches on this expression". This forces the developer to explicitly decide how to handle the cancelled state before the code can be deployed.
Trade-offs and Limitations
While DUs provide immense safety, they introduce specific engineering challenges:
- Serialization: Standard JSON serializers (like
System.Text.Json) do not natively understand F# DUs. They often serialize them as complex objects with a Choice wrapper. To interface with a REST API, you will likely need to map your DUs to simple Data Transfer Objects (DTOs) or use a library like FsToolkit.ErrorHandling. - Maintenance Overhead: In a very large codebase, adding a new case to a widely used DU can trigger dozens of compiler warnings. While this is a feature (it ensures correctness), it requires a systematic update across the application.
- Nesting Complexity: Deeply nested DUs can lead to "pyramids of doom" in pattern matching. If a match expression exceeds 5-10 cases, it is a signal to decompose the logic into smaller, specialized functions.
Closing Action
The next time you find yourself adding a bool flag to a class to track a state change, stop and ask: "Could this be a Discriminated Union?" If the state of your object changes the kind of data it needs to hold, a DU is the correct tool to ensure your domain logic remains robust and self-documenting.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.