Making Illegal States Unrepresentable
Design types that prevent invalid data at compile time, eliminating runtime validation and defensive programming.
Making Illegal States Unrepresentable
Master the art of designing type-safe systems with free flashcards and spaced repetition practice. This lesson covers discriminated unions, type-driven design, and domain modeling in F#βessential concepts for building robust, error-free applications that leverage the compiler to catch bugs before runtime.
Welcome π»
Have you ever shipped a bug because your code allowed a combination of values that should never exist together? Maybe a customer order was marked as "shipped" but had no shipping address, or a user account was both "active" and "deleted" simultaneously? These are illegal statesβimpossible or nonsensical combinations of data that your type system should prevent.
F# gives you powerful tools to make these illegal states literally unrepresentable in your code. By carefully designing your types, you can move entire classes of runtime errors into compile-time errors. The compiler becomes your ally, refusing to compile code that would create invalid data.
This approach, called type-driven design, transforms how you think about software architecture. Instead of writing defensive validation code everywhere, you encode business rules directly into your types. If it compiles, it's correctβor at least much closer to correct.
Core Concepts π―
The Problem: Primitive Obsession
Many developers model domain concepts using primitive types like strings, integers, and booleans. This creates opportunities for illegal states:
type Customer = {
Name: string
Email: string
IsVerified: bool
VerificationCode: string option
}
What's wrong here? Nothing prevents this illegal state:
let badCustomer = {
Name = "Alice"
Email = "alice@example.com"
IsVerified = true
VerificationCode = Some "ABC123" // β Verified but has code?
}
A verified customer shouldn't have a pending verification code, but the type system allows it. You'll need runtime validation everywhere you use this type.
The Solution: Discriminated Unions
Discriminated unions (also called sum types or algebraic data types) let you express "this OR that" relationships. Each case can carry different associated data:
type VerificationStatus =
| Unverified of code: string
| Verified
type Customer = {
Name: string
Email: string
Status: VerificationStatus
}
Now illegal states are impossible to represent:
let goodCustomer = {
Name = "Alice"
Email = "alice@example.com"
Status = Verified // β
No way to add a code here!
}
let pendingCustomer = {
Name = "Bob"
Email = "bob@example.com"
Status = Unverified "XYZ789" // β
Unverified requires a code
}
π‘ Key insight: The type system enforces your business rules. You can't accidentally create a verified customer with a verification code because the types won't allow it.
Making States Explicit
Consider a shopping cart that can be in different states:
| State | Properties | Allowed Operations |
|---|---|---|
| Empty | None | Add item |
| Active | Items list | Add/remove items, checkout |
| Paid | Items, payment info | Ship |
β Bad approach (strings and nulls):
type ShoppingCart = {
Items: Product list
Status: string // "empty", "active", "paid"
PaymentInfo: PaymentInfo option
}
Problems:
- Typos:
Status = "activ"compiles fine - Illegal states: empty cart with payment info
- No compiler guidance on what operations are valid
β Better approach (discriminated unions):
type CartState =
| EmptyCart
| ActiveCart of items: Product list
| PaidCart of items: Product list * payment: PaymentInfo
type ShoppingCart = {
State: CartState
}
Benefits:
- Type safety: Can't typo state names
- Impossible illegal states: Can't have empty cart with payment
- Exhaustive matching: Compiler ensures you handle all cases
Pattern Matching: Working with Union Types
When you use discriminated unions, you pattern match to handle each case:
let checkout cart =
match cart.State with
| EmptyCart ->
Error "Cannot checkout an empty cart"
| ActiveCart items ->
processPayment items
| PaidCart _ ->
Error "Cart already paid"
The compiler forces completeness. Forget a case and you get a compile error. This is dramatically different from if-else chains with strings, where missing a case just fails at runtime.
Single-Case Unions: Wrapping Primitives
Even single values benefit from wrapping. This prevents mixing up similar types:
type CustomerId = CustomerId of int
type OrderId = OrderId of int
let getCustomer (CustomerId id) =
database.GetCustomer(id)
let getOrder (OrderId id) =
database.GetOrder(id)
Now this won't compile:
let customerId = CustomerId 123
let order = getOrder customerId // β Type mismatch!
π‘ This technique is called "primitive wrapping" or "tiny types." It costs nothing at runtime (F# optimizes away the wrapper) but adds compile-time safety.
State Machines with Types
Many domains are naturally state machines. Model them explicitly:
type ConnectionState =
| Disconnected
| Connecting of attempt: int
| Connected of socket: Socket
| Failed of error: string
let connect state =
match state with
| Disconnected ->
Connecting 1
| Connecting attempt when attempt < 3 ->
Connecting (attempt + 1)
| Connecting _ ->
Failed "Too many attempts"
| Connected _ ->
state // Already connected
| Failed _ ->
state // Don't retry failed connections
CONNECTION STATE MACHINE
βββββββββββββββ
βDisconnected β
ββββββββ¬βββββββ
β
β
βββββββββββββββ
β Connecting βββββ
β (attempt n) β β Retry (n<3)
ββββββββ¬βββββββ β
β β
ββββββ΄βββββ β
β β β
βββββββββββ ββββββββ΄ββββββ
βConnectedβ β Failed β
β β β(max retries)β
βββββββββββ ββββββββββββββ
Each state transition is explicit. You can't go from Disconnected directly to Connected without going through Connectingβthe types won't allow it.
Validating at Boundaries
Not all constraints can be encoded in types. For example, email format validation requires parsing:
type EmailAddress = private EmailAddress of string
module EmailAddress =
let create (str: string) =
if str.Contains("@") && str.Length > 3 then
Ok (EmailAddress str)
else
Error "Invalid email format"
let value (EmailAddress str) = str
Key points:
- Constructor is privateβcan't create directly
- Must use
createfunction which validates - Once created, guaranteed to be valid
- This is called a smart constructor pattern
π‘ "Parse, don't validate" is the mantra. Once data passes the boundary (parsing), its type guarantees validity throughout your system.
Option vs. Result
F# provides two key types for "something might be missing":
| Type | Use Case | Values |
|---|---|---|
Option<'T> | Value may or may not exist | Some value or None |
Result<'T,'TError> | Operation may succeed or fail | Ok value or Error reason |
// Option: might not have a value
let findCustomer (id: CustomerId) : Customer option =
// Returns Some customer or None
// Result: might fail with error info
let validateAge (age: int) : Result<Age, string> =
if age >= 0 && age <= 120 then
Ok (Age age)
else
Error $"Age {age} is out of valid range"
Use Option when absence is normal (customer might not exist). Use Result when you need to explain why something failed.
Examples with Explanations π
Example 1: Payment Processing
Let's model a payment flow that goes through multiple states:
type PaymentAmount = PaymentAmount of decimal
type TransactionId = TransactionId of string
type PaymentState =
| NotStarted
| Pending of amount: PaymentAmount
| Authorized of amount: PaymentAmount * transactionId: TransactionId
| Captured of amount: PaymentAmount * transactionId: TransactionId
| Failed of reason: string
| Refunded of amount: PaymentAmount * transactionId: TransactionId
type Payment = {
OrderId: OrderId
State: PaymentState
}
Why this works:
- Can't capture without authorization: The
Capturedcase requires aTransactionId, which only comes fromAuthorized - Can't refund what wasn't captured:
Refundedrequires the same data asCaptured - Failure reason is explicit: Failed state must include why
Now implement state transitions:
let authorize payment amount transactionId =
match payment.State with
| NotStarted | Pending _ ->
{ payment with State = Authorized(amount, transactionId) }
| _ ->
payment // Can't authorize if not pending
let capture payment =
match payment.State with
| Authorized (amount, txId) ->
{ payment with State = Captured(amount, txId) }
| _ ->
payment // Can only capture authorized payments
let refund payment =
match payment.State with
| Captured (amount, txId) ->
{ payment with State = Refunded(amount, txId) }
| _ ->
payment // Can only refund captured payments
π§ Memory device: Think of states as a pipeline. Each stage requires outputs from the previous stage. Types enforce the pipeline.
Example 2: Form Validation
Consider a user registration form with progressive validation:
type UnvalidatedForm = {
Name: string
Email: string
Age: string
}
type ValidatedEmail = private ValidatedEmail of string
type ValidatedAge = private ValidatedAge of int
type ValidatedForm = {
Name: string
Email: ValidatedEmail
Age: ValidatedAge
}
type SavedForm = {
Form: ValidatedForm
UserId: UserId
Timestamp: DateTime
}
Three distinct types:
UnvalidatedForm- raw user input (all strings)ValidatedForm- passed validation (strong types)SavedForm- persisted to database (has ID and timestamp)
Validation pipeline:
module ValidatedEmail =
let create email =
if System.Text.RegularExpressions.Regex.IsMatch(
email, @"^[^@\s]+@[^@\s]+\.[^@\s]+$") then
Ok (ValidatedEmail email)
else
Error "Invalid email format"
module ValidatedAge =
let create ageStr =
match System.Int32.TryParse(ageStr) with
| true, age when age >= 13 && age <= 120 ->
Ok (ValidatedAge age)
| true, age ->
Error $"Age {age} is out of range"
| false, _ ->
Error "Age must be a number"
let validateForm (form: UnvalidatedForm) =
// Result composition
match ValidatedEmail.create form.Email,
ValidatedAge.create form.Age with
| Ok email, Ok age ->
Ok { Name = form.Name; Email = email; Age = age }
| Error e, _ | _, Error e ->
Error e
Now your saveToDatabase function can require ValidatedForm:
let saveToDatabase (form: ValidatedForm) =
// No validation needed here!
// Type guarantees data is already valid
let userId = UserId (Guid.NewGuid())
{ Form = form; UserId = userId; Timestamp = DateTime.UtcNow }
π§ Try this: Add a VerifiedForm type that requires email verification. Functions that need verified emails can't accept merely ValidatedForm.
Example 3: API Request Modeling
Model different API request/response scenarios:
type ApiRequest<'T> =
| NotAsked
| Loading
| Success of data: 'T
| Failure of error: string
type UserProfile = {
Username: string
Bio: string
}
type PageModel = {
UserData: ApiRequest<UserProfile>
}
UI rendering becomes exhaustive:
let renderUserProfile model =
match model.UserData with
| NotAsked ->
"<div>Click to load profile</div>"
| Loading ->
"<div class='spinner'>Loading...</div>"
| Success user ->
$"<div><h1>{user.Username}</h1><p>{user.Bio}</p></div>"
| Failure err ->
$"<div class='error'>Failed: {err}</div>"
Benefits:
- Compiler ensures you handle loading states
- Can't accidentally show stale data during refresh
- Clear distinction between "not started" and "failed"
π€ Did you know? This pattern comes from Elm architecture and is widely used in frontend F# frameworks like Fable.
Example 4: Document Workflow
Model a document approval workflow with role-based permissions:
type DocumentId = DocumentId of Guid
type UserId = UserId of Guid
type DraftDocument = {
Id: DocumentId
Content: string
Author: UserId
}
type SubmittedDocument = {
Draft: DraftDocument
SubmittedAt: DateTime
}
type ApprovedDocument = {
Submitted: SubmittedDocument
ApprovedBy: UserId
ApprovedAt: DateTime
}
type PublishedDocument = {
Approved: ApprovedDocument
PublishedUrl: string
}
type DocumentState =
| Draft of DraftDocument
| Submitted of SubmittedDocument
| Approved of ApprovedDocument
| Published of PublishedDocument
| Rejected of document: SubmittedDocument * reason: string
Role-based operations:
let submit (draft: DraftDocument) : SubmittedDocument =
{ Draft = draft; SubmittedAt = DateTime.UtcNow }
// Only managers can approve
let approve (submitted: SubmittedDocument) (managerId: UserId) : ApprovedDocument =
{ Submitted = submitted
ApprovedBy = managerId
ApprovedAt = DateTime.UtcNow }
// Only IT can publish
let publish (approved: ApprovedDocument) (url: string) : PublishedDocument =
{ Approved = approved; PublishedUrl = url }
Notice:
- Can't publish a draftβmust go through approval
- Each stage preserves previous stage data (nested records)
- Type signatures document the workflow
DOCUMENT WORKFLOW STATE MACHINE
βββββββββββ
β Draft β
ββββββ¬βββββ
β
β
βββββββββββ
βSubmittedβ
ββββββ¬βββββ
β
βββββ΄ββββ
β β
ββββββββ ββββββββββ
βRejectβ βApprovedβ
ββββββββ βββββ¬βββββ
β
β
βββββββββββ
βPublishedβ
βββββββββββ
Common Mistakes β οΈ
Mistake 1: Using Booleans for Multi-State Flags
β Wrong:
type Order = {
IsPaid: bool
IsShipped: bool
IsCancelled: bool
}
// What if IsPaid=true AND IsCancelled=true? π±
β Right:
type OrderStatus =
| Pending
| Paid
| Shipped
| Cancelled
type Order = {
Status: OrderStatus
}
Mistake 2: Forgetting to Make Constructors Private
β Wrong:
type EmailAddress = EmailAddress of string
// Anyone can create invalid emails!
let bad = EmailAddress "not-an-email"
β Right:
type EmailAddress = private EmailAddress of string
module EmailAddress =
let create str =
// validation here
if isValid str then Ok (EmailAddress str)
else Error "Invalid email"
Mistake 3: Not Carrying Forward Required Data
β Wrong:
type CartState =
| HasItems
| CheckedOut
// Where are the items when checked out? Lost! π±
β Right:
type CartState =
| HasItems of items: Product list
| CheckedOut of items: Product list * payment: PaymentInfo
Mistake 4: Using Strings for Enumerations
β Wrong:
type Config = {
Environment: string // "dev", "staging", "prod"
}
let config = { Environment = "production" } // Typo! π±
β Right:
type Environment =
| Development
| Staging
| Production
type Config = {
Environment: Environment
}
let config = { Environment = Production } // Type-safe!
Mistake 5: Over-Validating Internal Code
β Wrong:
type ValidatedEmail = private ValidatedEmail of string
let sendEmail (ValidatedEmail email) =
// Validating again even though type guarantees validity!
if email.Contains("@") then
actualSend email
else
Error "Invalid email"
β Right:
let sendEmail (ValidatedEmail email) =
// Trust the type! No validation needed
actualSend email
π‘ Principle: Validate at the boundary (when creating the type), then trust the type everywhere else.
Key Takeaways π
- Discriminated unions let you model mutually exclusive states explicitly
- Pattern matching ensures exhaustive handling of all cases
- Smart constructors (private constructors + create functions) enforce validation
- Single-case unions wrap primitives to prevent mix-ups
- "Make illegal states unrepresentable" pushes errors from runtime to compile-time
- Result type carries error information; Option type represents presence/absence
- State machines map naturally to discriminated unions
- Trust your types after validationβdon't re-validate internally
Further Study π
- Domain Modeling Made Functional by Scott Wlaschin - Comprehensive guide to type-driven design in F#
- Designing with Types series - Deep dive into making illegal states unrepresentable
- F# for Fun and Profit: Algebraic Type Sizes - Mathematical perspective on type design
π Quick Reference Card
| Discriminated Union | Type with multiple named cases, each with different data |
| Pattern Matching | Exhaustive branching on union cases |
| Smart Constructor | Private constructor + public validation function |
| Single-Case Union | Wrapper type to prevent mixing primitives |
| Option<'T> | Some value or None |
| Result<'T,'E> | Ok value or Error reason |
| Illegal State | Invalid combination of values that shouldn't coexist |
| Type-Driven Design | Encode business rules in types, not runtime checks |