Replacing Boolean Flags with F# Discriminated Unions for Safer Domain Models
Boolean flags and nullable fields create combinatorial state bugs. F# Discriminated Unions make illegal states unrepresentable by encoding each valid state as a distinct case with its own required data—verified exhaustive by the compiler.
18 Sept 2026, 11:05 UTC

The Problem with Boolean Flags and Nulls
Most developers have encountered code littered with boolean flags like isPaid, isPending, hasError scattered across a domain object. Add a few nullable fields and you get combinatorial state explosions: what happens when isPaid=true but paymentDate=null? What if isPending=false and isPaid=false simultaneously? These "impossible" states manifest as runtime bugs, defensive null checks, and conditional logic that grows harder to maintain with each new requirement.
F# Discriminated Unions (DUs) solve this by making illegal states unrepresentable at compile time. Instead of tracking multiple independent flags, you define a single type whose cases represent every valid state explicitly—each carrying only the data relevant to that state.
How Discriminated Unions Model State
A Discriminated Union is a type that can be exactly one of several named cases. Each case may hold different data. Think of it as a type-safe sum type: PaymentStatus = Pending | Paid of DateTime * TransactionId | Failed of string. The vertical bars separate cases; the of keyword attaches payload data to a case.
This replaces a class with three booleans, a nullable date, a nullable transaction ID, and a nullable error message. The compiler guarantees that a Paid value always carries a date and transaction ID, while a Failed value always carries an error reason. You cannot construct a Paid without those fields, and you cannot accidentally read a transaction ID from a Pending payment.
Worked Example: Payment Processing
Consider a payment domain where a payment moves through states. In a flag-based C# approach, you might write:
public class Payment {
public bool IsPending { get; set; }
public bool IsPaid { get; set; }
public bool IsFailed { get; set; }
public DateTime? PaidAt { get; set; }
public string TransactionId { get; set; }
public string ErrorMessage { get; set; }
}
Every method must check flag combinations defensively. Now the F# DU equivalent:
type TransactionId = TransactionId of string
type PaymentStatus =
| Pending
| Paid of paidAt: DateTime * transactionId: TransactionId
| Failed of reason: string
type Payment = {
Id: Guid
Amount: decimal
Status: PaymentStatus
}
Each case carries precisely its required data. Paid requires both a timestamp and a transaction ID. Failed requires a reason. Pending carries nothing extra.
Handling this in business logic uses pattern matching:
let describePayment (payment: Payment) =
match payment.Status with
| Pending -> "Awaiting processing"
| Paid (paidAt, txnId) ->
sprintf "Paid on %s (txn: %s)" paidAt.ToString("u") txnId
| Failed reason -> sprintf "Failed: %s" reason
The match expression is exhaustive. If you later add a Refunded case to PaymentStatus, the compiler emits a warning (or error with --warnon:25) at every match site that doesn't handle it. No runtime surprise when a new state appears.
Verifying Exhaustiveness
To confirm the compiler catches missing cases, remove one branch from the match expression above and build the project. In a terminal at the project root, run:
dotnet build
You'll see warning FS0025: "Incomplete pattern matches on this expression. For example, the value 'Failed _' may indicate a case not covered by the pattern(s)." This is the compiler proving your logic handles every state. Restore the missing branch to clear the warning.
Trade-offs and Limitations
DUs are not free. Interoperating with C# requires work: F# compiles DUs to abstract base classes with nested subclasses for each case. Consuming them from C# means pattern matching via switch on the runtime type or using the Is* properties and Item accessors that the compiler generates. For public APIs consumed heavily by C# callers, consider exposing a simpler facade or using the FSharp.Core union helpers.
Deeply nested DUs can produce verbose match expressions. The remedy is decomposition: extract sub-match logic into small, named functions. For example, a PaymentResult DU containing a PaymentStatus can be handled by a formatStatus function, keeping the top-level match readable.
When to Reach for a Discriminated Union
Use a DU whenever you have a value that can be one of several distinct shapes, especially when each shape carries different data. Common candidates: order status, authentication result, parsing outcome, UI view state, or any workflow with discrete steps. If you find yourself writing if (x != null && y == null) or checking multiple booleans to determine what data is valid, that's a signal to model the state as a DU.
Start small. Replace a single flag-heavy class with a DU and a match expression. Verify the compiler warns on missing cases. Then expand. The payoff is code that documents its own invariants and refuses to compile when those invariants are violated.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.