Using F# Discriminated Unions to Model Domain States Safely
Discriminated unions in F# let you define a closed set of domain states and get compile‑time exhaustiveness checks when you pattern match, reducing boilerplate and preventing missed cases.
28 Nov 2025, 19:19 UTC

Problem: Modeling domain states without missing cases
When you model domain concepts such as user account status, order processing steps, or game entities, you often end up with a set of distinct states. Using strings, integers, or scattered boolean flags makes it easy to forget a case or to introduce invalid combinations. The compiler cannot help you spot missing handling, which leads to runtime bugs that are hard to trace.
How discriminated unions help
A discriminated union (also called a tagged union or algebraic data type) lets you declare a closed set of shapes, each with its own associated data. The F# compiler knows every possible case and can check that you handle them all when you pattern match. This replaces long if‑else chains or visitor patterns with a clear, declarative match expression.
Worked example: calculating the area of a shape
type Shape =
| Circle of radius:float
| Rectangle of width:float * height:float
| Triangle of sideA:float * sideB:float * angle:float
let area shape =
match shape with
| Circle r -> System.Math.PI * r * r
| Rectangle (w, h) -> w * h
| Triangle (a, b, ang) -> 0.5 * a * b * System.Math.Sin(ang)
If you omit one of the branches, for example the Triangle case, the compiler emits warning FS0025: "Incomplete pattern matches on this expression. For example, the value 'Triangle (_,_,_)' is not matched." This warning appears at compile time, so you cannot ship code that forgets a case.
Trade‑offs and limitations
- Very large discriminated unions with many alternatives produce long match expressions. When a union grows beyond a handful of cases, consider splitting it into smaller, related unions or using active patterns to group logic.
- Serializing a discriminated union to JSON is not straightforward. The default representation (e.g., {"Circle":5.0}) varies by serializer and may not be what external systems expect. You typically need a custom System.Text.Json converter that encodes each case with a tag property, such as {"type":"Circle","radius":5.0}.
Actionable next steps
- Create a new F# console project:
dotnet new console -lang F# -n DomainModel - Add the union and the
areafunction shown above to a file such asProgram.fs. - Build the project:
dotnet build. Verify that the build succeeds. - Experiment: comment out the
Trianglebranch in the match expression and rebuild. You should see warning FS0025 indicating an incomplete match. - Restore the branch, then add a new case, for example
| Ellipse of rx:float * ry:float, and rebuild. The compiler will flag the existingareafunction as incomplete, prompting you to update it. - For JSON serialization, add a System.Text.Json converter that writes a "type" discriminator and the associated fields. Test the converter with a unit round‑trip to ensure the produced JSON matches the expected format.
By using discriminated unions you gain a compile‑time safeguard that keeps your domain model consistent as it evolves, while keeping the code concise and expressive.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.