Discriminated Unions
Prerequisites: Records
A record is "this and that". A discriminated union is "this or that": a value that is exactly one of several named cases, each of which can carry its own data.
type Shape =
| Circle of radius: float
| Rectangle of width: float * height: float
| Triangle of a: float * b: float * c: float
let area shape =
match shape with
| Circle r -> System.Math.PI * r * r
| Rectangle (w, h) -> w * h
| Triangle (a, b, c) ->
let s = (a + b + c) / 2.0
sqrt (s * (s - a) * (s - b) * (s - c))
let shapes = [ Circle 1.0; Rectangle (3.0, 4.0); Triangle (3.0, 4.0, 5.0) ]
for s in shapes do
printfn "%A has area %.2f" s (area s)
Circle 1.0 has area 3.14
Rectangle (3.0, 4.0) has area 12.00
Triangle (3.0, 4.0, 5.0) has area 6.00
- Each case name is also a constructor:
Circle 1.0creates aShape. - Naming the fields (
radius:) is optional but documents them. matchtakes each case apart. If you add a new case later, the compiler warns about everymatchthat doesn't handle it — the union is a checklist the compiler enforces.
Cases without data
Unions with no data are like enums, but safer:
type Suit = Hearts | Diamonds | Clubs | Spades
let color suit =
match suit with
| Hearts | Diamonds -> "red"
| Clubs | Spades -> "black"
printfn "%s %s" (color Hearts) (color Spades)
printfn "%A" [Hearts; Clubs]
red black
[Hearts; Clubs]
Modeling states
Unions shine when something can be in one of several states, each with different data:
type PaymentStatus =
| Pending
| Paid of amount: decimal * reference: string
| Refunded of amount: decimal * reason: string
| Failed of error: string
let describe status =
match status with
| Pending -> "waiting for payment"
| Paid (amount, reference) -> $"paid {amount} (ref {reference})"
| Refunded (amount, reason) -> $"refunded {amount}: {reason}"
| Failed error -> $"failed: {error}"
[ Pending; Paid (25m, "TX-81"); Refunded (25m, "damaged"); Failed "card declined" ]
|> List.iter (describe >> printfn "%s")
waiting for payment
paid 25 (ref TX-81)
refunded 25: damaged
failed: card declined
Compare that with a class that has a Status string plus nullable
Amount, Reference, Reason, and Error fields: the union makes
impossible combinations (a pending payment with a refund reason)
impossible to write.
Recursive unions
A union case can contain the union itself — perfect for trees and expressions:
type Expr =
| Num of int
| Add of Expr * Expr
| Mul of Expr * Expr
let rec eval expr =
match expr with
| Num n -> n
| Add (a, b) -> eval a + eval b
| Mul (a, b) -> eval a * eval b
let rec show expr =
match expr with
| Num n -> string n
| Add (a, b) -> $"({show a} + {show b})"
| Mul (a, b) -> $"{show a} * {show b}"
let e = Mul (Num 2, Add (Num 3, Num 4))
printfn "%s = %d" (show e) (eval e)
2 * (3 + 4) = 14
Single-case unions
A union with one case wraps a value in its own type, so you can't mix up two values that are both "just an int":
type CustomerId = CustomerId of int
type OrderId = OrderId of int
let describeOrder (OrderId o) (CustomerId c) = $"order {o} for customer {c}"
printfn "%s" (describeOrder (OrderId 7) (CustomerId 42))
order 7 for customer 42
Passing CustomerId 42 where an OrderId is expected is now a compile
error. Notice the pattern (OrderId o) right in the parameter list —
it unwraps the value. Level 3 builds on this for domain modeling.
Practice
Define a union Temperature with cases Celsius of float and
Fahrenheit of float, and a function toCelsius that converts either
one. Print both converted values for Fahrenheit 212.0 and
Celsius 21.5.
Show solution
type Temperature =
| Celsius of float
| Fahrenheit of float
let toCelsius t =
match t with
| Celsius c -> c
| Fahrenheit f -> (f - 32.0) * 5.0 / 9.0
for t in [ Fahrenheit 212.0; Celsius 21.5 ] do
printfn "%A = %.1f C" t (toCelsius t)
Fahrenheit 212.0 = 100.0 C
Celsius 21.5 = 21.5 C