Skip to content
AgileLogics
7 min read

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.0 creates a Shape.
  • Naming the fields (radius:) is optional but documents them.
  • match takes each case apart. If you add a new case later, the compiler warns about every match that 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