You are viewing a preview of this lesson. Sign in to start learning
Back to Functional Programming with F#

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:

StatePropertiesAllowed Operations
EmptyNoneAdd item
ActiveItems listAdd/remove items, checkout
PaidItems, payment infoShip

❌ 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 create function 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":

TypeUse CaseValues
Option<'T>Value may or may not existSome value or None
Result<'T,'TError>Operation may succeed or failOk 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:

  1. Can't capture without authorization: The Captured case requires a TransactionId, which only comes from Authorized
  2. Can't refund what wasn't captured: Refunded requires the same data as Captured
  3. 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:

  1. UnvalidatedForm - raw user input (all strings)
  2. ValidatedForm - passed validation (strong types)
  3. 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 πŸŽ“

  1. Discriminated unions let you model mutually exclusive states explicitly
  2. Pattern matching ensures exhaustive handling of all cases
  3. Smart constructors (private constructors + create functions) enforce validation
  4. Single-case unions wrap primitives to prevent mix-ups
  5. "Make illegal states unrepresentable" pushes errors from runtime to compile-time
  6. Result type carries error information; Option type represents presence/absence
  7. State machines map naturally to discriminated unions
  8. Trust your types after validationβ€”don't re-validate internally

Further Study πŸ“š

πŸ“‹ Quick Reference Card

Discriminated UnionType with multiple named cases, each with different data
Pattern MatchingExhaustive branching on union cases
Smart ConstructorPrivate constructor + public validation function
Single-Case UnionWrapper type to prevent mixing primitives
Option<'T>Some value or None
Result<'T,'E>Ok value or Error reason
Illegal StateInvalid combination of values that shouldn't coexist
Type-Driven DesignEncode business rules in types, not runtime checks

Practice Questions

Test your understanding with these questions:

Q1: Complete the discriminated union for a traffic light: ```fsharp type TrafficLight = | Red | {{1}} | Green ```
A: Yellow
Q2: What does this F# code enforce? ```fsharp type PaymentStatus = | Unpaid | Paid of transactionId: string let refund status = match status with | Paid txId -> processRefund txId | Unpaid -> Error "Cannot refund unpaid order" ``` A. Performance optimization B. Illegal states are unrepresentable C. Database transactions D. Asynchronous processing E. Memory management
A: B
Q3: Complete the smart constructor pattern: ```fsharp type EmailAddress = {{1}} EmailAddress of string module EmailAddress = let create str = if str.Contains("@") then Ok (EmailAddress str) else Error "Invalid" ```
A: private
Q4: What does this code print? ```fsharp type Result<'T,'E> = | Ok of 'T | Error of 'E let result = Ok 42 match result with | Ok value -> printfn "%d" value | Error _ -> printfn "Failed" ``` A. Failed B. 42 C. Ok 42 D. Error E. Nothing
A: B
Q5: Fill in the Option type usage: ```fsharp let findUser id = if id > 0 then {{1}} "Alice" else {{2}} ```
A: ["Some","None"]