Stop Using Boolean Flags: Modeling State with F# Discriminated Unions
Stop relying on boolean flags to manage state. Learn how F# Discriminated Unions make illegal states unrepresentable and eliminate null-reference exceptions.
24 May 2026, 08:28 UTC

The Problem with Boolean State
Many developers manage object states using a collection of boolean flags. Consider an order processing system where you track IsPending, IsShipped, and IsDelivered. While simple to start, this creates a state space where it is mathematically possible for an order to be simultaneously pending, shipped, and delivered—an illegal state that your business logic must now manually guard against using complex if/else chains.
The goal is to make illegal states unrepresentable. In F#, the primary tool for this is the Discriminated Union (DU). A DU allows you to define a type that can be exactly one of several different cases, each potentially carrying its own unique data.
Defining Domain-Driven States
Instead of multiple flags, a DU collapses these possibilities into a single type. This ensures that the system can never be in two states at once. If an order is Shipped, it cannot also be Pending because the type system only allows one case to be active.
Beyond simple labels, DUs allow you to attach data specifically to the state that needs it. For example, a Pending order needs nothing, but a Shipped order requires a tracking number. By attaching the tracking number only to the Shipped case, you eliminate the need for nullable fields in your main record.
Worked Example: Order Lifecycle
Below is a implementation of an order state machine. This code should be run in an F# Interactive (FSI) session or a .NET console project using F# 6.0 or later.
type OrderState =
| Pending
| Shipped of TrackingNumber: string
| Delivered of DeliveryDate: System.DateTime
| Cancelled of Reason: string
let getOrderSummary state =
match state with
| Pending -> "Order is awaiting processing."
| Shipped trackNum -> sprintf "Order is on the way. Tracking: %s" trackNum
| Delivered date -> sprintf "Order delivered on %s" (date.ToShortDateString())
| Cancelled reason -> sprintf "Order cancelled: %s" reason
Verification Step: To test the safety of this approach, try removing the | Cancelled reason line from the match expression. The F# compiler will issue a warning: Incomplete pattern matches on this expression. For example, the value 'Cancelled _' may have been overlooked. This forces the developer to handle every possible business state before the code even runs.
Handling Optionality with the Option Type
Nulls are a frequent source of runtime exceptions. F# replaces nulls with the Option<T> type, which is itself a specialized Discriminated Union with two cases: Some value and None.
When a function returns an Option, the compiler prevents you from using the inner value until you explicitly check if it exists. This shifts the burden of null-checking from a runtime memory gamble to a compile-time requirement.
Trade-offs and Limitations
While DUs provide immense safety, they introduce specific engineering challenges:
- Breaking Changes: Adding a new case to a DU (e.g., adding
ReturnedtoOrderState) is a breaking change. Everymatchexpression across your entire codebase will now trigger a compiler warning until the new case is handled. - C# Interop: When consumed from C#, F# DUs are represented as a class hierarchy. C# developers lose the native exhaustive matching syntax and must use pattern matching or type casting, which is less ergonomic than the F# experience.
- Nesting Complexity: Deeply nested DUs can lead to "pyramids of doom" in pattern matching. In these cases, it is better to decompose logic into smaller helper functions that handle individual union cases.
Practical Implementation Checklist
To transition from boolean flags to DUs, follow these steps:
- Identify all boolean flags that describe the status of an entity.
- Group these flags into a single
type State = ...Discriminated Union. - Move state-specific data (like IDs or dates) from the main record into the specific DU case.
- Replace
if/elseblocks withmatchexpressions to ensure exhaustive handling.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.