Modeling Domain States with F# Discriminated Unions
Learn how to use F# Discriminated Unions to eliminate invalid domain states and leverage exhaustive pattern matching for more robust, type-safe applications.
01 Jan 2026, 02:52 UTC

Eliminating Invalid States in Domain Logic
A common problem in application development is the "invalid state," where a set of independent variables (like a boolean IsProcessing and a string ErrorMessage) can exist in combinations that make no logical sense—such as having an error message while the system is still processing. This leads to defensive null checks and fragile if/else chains.
The solution is the Discriminated Union (DU). Unlike a class hierarchy, a DU defines a closed set of possible states. By wrapping data within these states, you make it physically impossible for the application to be in an undefined or contradictory state.
Prerequisites
- .NET SDK installed (6.0 or later recommended).
- An F# project (Console or Library) created via
dotnet new console -lang \"F#\". - Basic familiarity with F# function syntax.
Implementing a Domain State Machine
Consider an order processing system. An order can be Pending, Shipped (with a tracking number), or Cancelled (with a reason). Using a DU ensures that a tracking number only exists if the order is actually shipped.
// Define the Discriminated Union
type OrderStatus =
| Pending
| Shipped of TrackingId: string
| Cancelled of Reason: string
// A function to generate a user-facing status message
let getStatusMessage status =
match status with
| Pending -> \"Your order is being prepared.\"
| Shipped trackingId -> sprintf \"Your order has shipped. Tracking: %s\" trackingId
| Cancelled reason -> sprintf \"Order cancelled. Reason: %s\" reason
Ensuring Exhaustive Handling
The primary engineering advantage of DUs is exhaustive pattern matching. When you use the match expression, the F# compiler analyzes the DU definition. If you fail to handle one of the cases, the compiler issues a warning.
Diagnostic Check: To verify this safety mechanism, temporarily comment out the | Cancelled reason line in the getStatusMessage function. Upon compilation, the F# compiler will report: Warning FS0025: Incomplete pattern matches on this expression. For example, the value 'Cancelled' may have been overlooked.
Handling Errors without Exceptions
F# provides built-in DUs for common engineering patterns: Option and Result. Instead of returning null or throwing exceptions for expected failures, use Result<T, TError>.
type ValidationError = EmptyField | InvalidFormat
let validateEmail email =
if System.String.IsNullOrWhiteSpace(email) then
Error EmptyField
else if not (email.Contains(\"@\")) then
Error InvalidFormat
else
Ok email
// Usage in a workflow
let processEmail input =
match validateEmail input with
| Ok validEmail -> sprintf \"Processing %s...\" validEmail
| Error EmptyField -> \"Please enter an email address.\"
| Error InvalidFormat -> \"The email format is incorrect.\"
Comparison: DU vs. Class Hierarchy
| Feature | Discriminated Union | Class Hierarchy (Inheritance) |
|---|---|---|
| Set Size | Closed (Fixed cases) | Open (New subclasses can be added) |
| Dispatch | Pattern Matching (External) | Virtual Methods (Internal) |
| Safety | Compile-time exhaustiveness check | Runtime polymorphism |
Limitations and Maintenance
While DUs provide high safety, they introduce a maintenance trade-off: The Fragile Match Problem. Adding a new case to a DU (e.g., adding | Returned of ReturnId: string to OrderStatus) will trigger compiler warnings across every match expression in your entire codebase that references that type.
To manage this in large projects:
- Break large unions into smaller, nested unions.
- Avoid using the wildcard pattern (
| _ -> ...) unless you truly want all future cases to be handled by a single default behavior; otherwise, you lose the compiler's exhaustiveness warnings.
Verification of Result
To verify the implementation, run the following test cases in an F# Interactive (FSI) session or a main entry point:
- Pass
PendingtogetStatusMessage$\\rightarrow$ Expect \"Your order is being prepared.\" - Pass
Shipped \"ABC123\"togetStatusMessage$\\rightarrow$ Expect \"Your order has shipped. Tracking: ABC123\" - Pass
\"\"(empty string) toprocessEmail$\\rightarrow$ Expect \"Please enter an email address.\"
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.