Designing with Types
Prerequisites: Discriminated unions
F#'s type system lets you encode business rules so that invalid states can't be represented — the compiler rejects them before any test runs. This style is often called "domain modeling made functional", after Scott Wlaschin's book of that name.
Constrained values with smart constructors
Wrap primitive values in single-case unions with a private
constructor, and provide a create function that validates:
module Domain =
type EmailAddress = private EmailAddress of string
module EmailAddress =
let create (s: string) =
if s.Contains "@" && s.Length <= 254 then Ok (EmailAddress (s.Trim().ToLower()))
else Error $"'{s}' is not a valid email"
let value (EmailAddress s) = s
type Quantity = private Quantity of int
module Quantity =
let create n =
if n >= 1 && n <= 100 then Ok (Quantity n)
else Error "quantity must be 1-100"
let value (Quantity n) = n
open Domain
match EmailAddress.create "Ada@Example.com" with
| Ok email -> printfn "valid: %s" (EmailAddress.value email)
| Error e -> printfn "%s" e
printfn "%A" (Quantity.create 0)
valid: ada@example.com
Error "quantity must be 1-100"
Outside the Domain module, the only way to get an EmailAddress is
through create — so every EmailAddress anywhere in the program is
known to be valid. Functions that take one never need to re-check.
States as union cases
Instead of a record full of optional fields and a status string, give each state its own case with exactly the data it needs:
type OrderLine = { Product: string; Price: decimal }
type Order =
| Draft of lines: OrderLine list
| Placed of lines: OrderLine list * placedOn: System.DateOnly
| Shipped of lines: OrderLine list * trackingNumber: string
| Cancelled of reason: string
let addLine line order =
match order with
| Draft lines -> Ok (Draft (line :: lines))
| _ -> Error "only draft orders can be changed"
let place date order =
match order with
| Draft [] -> Error "can't place an empty order"
| Draft lines -> Ok (Placed (lines, date))
| _ -> Error "order was already placed"
let ship tracking order =
match order with
| Placed (lines, _) -> Ok (Shipped (lines, tracking))
| _ -> Error "only placed orders can ship"
let today = System.DateOnly(2025, 3, 9)
let flow =
Ok (Draft [])
|> Result.bind (addLine { Product = "Tea"; Price = 3.5m })
|> Result.bind (place today)
|> Result.bind (ship "TRK-1234")
printfn "%A" flow
printfn "%A" (Draft [] |> place today)
printfn "%A" (Cancelled "customer request" |> ship "TRK-9")
Ok (Shipped ([{ Product = "Tea"
Price = 3.5M }], "TRK-1234"))
Error "can't place an empty order"
Error "only placed orders can ship"
You can't ship a draft, there's no tracking number on an unshipped
order, and a cancelled order has no lines to confuse anyone. The types
are also the documentation: a new team member can read the Order
type and understand the lifecycle.
Workflows as functions
A business process becomes a function type, which is easy to read, implement, and test:
type ValidateOrder = UnvalidatedOrder -> Result<ValidatedOrder, ValidationError list>
type PriceOrder = ValidatedOrder -> Result<PricedOrder, PricingError>
type PlaceOrder = UnvalidatedOrder -> Result<OrderPlaced list, PlaceOrderError>
Guidelines
- Push validation to the edges: parse raw input into domain types once, then work only with valid values ("parse, don't validate").
- Prefer unions to booleans and strings for states and choices.
- Keep the domain free of I/O — databases, HTTP, and files live in an outer layer that calls pure domain functions. This makes the core trivial to test.
- Wrap a primitive only when it has rules or could be confused with
another value; not every
stringneeds its own type.