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

Domain Modeling with Types

Use F#'s type system to encode business rules: records for data structures and discriminated unions for choices and workflows.

Domain Modeling with Types

Master domain modeling with types using F# through free flashcards and spaced repetition practice. This lesson covers algebraic data types, single-case unions, domain validation, and type-driven design—essential concepts for building robust functional applications that make illegal states unrepresentable.

Welcome 🎯

In F#, types are your first line of defense against bugs. Unlike object-oriented programming where you model domains with classes and inheritance, functional programming uses algebraic data types to create precise models that enforce business rules at compile time. This approach, often called "making illegal states unrepresentable," means many errors simply cannot happen.

💡 Key Insight: In F#, the type system is not just for avoiding null references—it's a powerful tool for encoding business logic and domain constraints directly into your code structure.

Core Concepts 📚

Algebraic Data Types (ADTs)

F# provides two fundamental building blocks for domain modeling:

1. Product Types (Record Types): Combine multiple values together

type Customer = {
    FirstName: string
    LastName: string
    Email: string
    Age: int
}

2. Sum Types (Discriminated Unions): Represent a choice between alternatives

type PaymentMethod =
    | Cash
    | CreditCard of cardNumber: string * cvv: string
    | PayPal of email: string
    | BankTransfer of accountNumber: string

🧠 Memory Device: Think "Product types are AND, Sum types are OR"—a customer has a name AND email AND age, but payment is Cash OR CreditCard OR PayPal.

Single-Case Unions: Wrapping Primitive Types

One of the most powerful patterns in F# domain modeling is wrapping primitives to create meaningful, constrained types:

type EmailAddress = EmailAddress of string
type Age = Age of int
type PostalCode = PostalCode of string

This prevents mixing up values:

// This compiles
let sendEmail (email: EmailAddress) = 
    // implementation

// This will NOT compile - type safety!
let postalCode = PostalCode "12345"
sendEmail postalCode  // ERROR: Expected EmailAddress, got PostalCode

Why this matters: You can never accidentally pass an age where an email is expected, or add a postal code to a phone number. The compiler catches these mistakes instantly.

Smart Constructors: Validation at the Boundary

Single-case unions become even more powerful when combined with smart constructors—functions that validate input and return a Result or Option:

type EmailAddress = private EmailAddress of string

module EmailAddress =
    let create (input: string) : Result<EmailAddress, string> =
        if System.String.IsNullOrWhiteSpace(input) then
            Error "Email cannot be empty"
        elif not (input.Contains("@")) then
            Error "Email must contain @"
        else
            Ok (EmailAddress input)
    
    let value (EmailAddress email) = email

🔒 The private keyword ensures the only way to create an EmailAddress is through the create function, which validates the input. Once you have an EmailAddress value, you know it's valid!

PatternPurposeExample
Primitive ObsessionUsing strings/ints everywherelet sendEmail (email: string)
Single-Case UnionType safety without validationtype Email = Email of string
Smart ConstructorType safety WITH validationEmailAddress.create "test@example.com"

Making Illegal States Unrepresentable

Consider modeling a customer order. Poor design might look like:

// ❌ BAD: Many illegal states possible
type OrderBad = {
    OrderId: string
    Status: string  // "pending", "paid", "shipped", "cancelled"?
    PaymentMethod: string option  // When should this be Some vs None?
    ShippingAddress: string option
    TrackingNumber: string option
}

Problems:

  • What if Status = "shipped" but TrackingNumber = None?
  • What if Status = "cancelled" but PaymentMethod = Some "...."?
  • What prevents Status = "typo"?

Better design using discriminated unions:

// ✅ GOOD: Only valid states are possible
type UnpaidOrder = {
    OrderId: OrderId
    Items: OrderItem list
}

type PaidOrder = {
    OrderId: OrderId
    Items: OrderItem list
    PaymentMethod: PaymentMethod
    ShippingAddress: Address
}

type ShippedOrder = {
    OrderId: OrderId
    Items: OrderItem list
    PaymentMethod: PaymentMethod
    ShippingAddress: Address
    TrackingNumber: TrackingNumber
}

type CancelledOrder = {
    OrderId: OrderId
    Reason: string
}

type Order =
    | Unpaid of UnpaidOrder
    | Paid of PaidOrder
    | Shipped of ShippedOrder
    | Cancelled of CancelledOrder

Now it's impossible to have a shipped order without a tracking number, or a cancelled order with payment details. The type system enforces your business rules!

Pattern Matching: Exhaustive and Safe

When working with discriminated unions, F#'s pattern matching ensures you handle every possible case:

let getOrderStatus order =
    match order with
    | Unpaid _ -> "Awaiting payment"
    | Paid _ -> "Payment received, preparing shipment"
    | Shipped s -> sprintf "Shipped! Tracking: %s" (TrackingNumber.value s.TrackingNumber)
    | Cancelled c -> sprintf "Cancelled: %s" c.Reason

💡 Compiler Protection: If you add a new order state later (like Returned), the compiler will flag every pattern match that doesn't handle it. No runtime surprises!

Composition: Building Complex Types

Domain models naturally compose from smaller pieces:

type PersonalName = {
    FirstName: String50
    LastName: String50
}

type ContactInfo = {
    Email: EmailAddress
    Phone: PhoneNumber option
}

type Customer = {
    Name: PersonalName
    Contact: ContactInfo
    BillingAddress: Address
    ShippingAddress: Address option
    PreferredPayment: PaymentMethod
}

Each component (PersonalName, ContactInfo, etc.) can have its own validation logic and smart constructors, keeping complexity manageable.

🌍 Real-World Analogy: Think of domain types like LEGO blocks. Each piece (EmailAddress, PhoneNumber, Address) is validated and correct on its own. You snap them together to build complex structures (Customer, Order), but you can't accidentally connect incompatible pieces—the type system is like the physical shape of LEGO connectors.

Result Type: Elegant Error Handling

F# uses the Result<'T, 'Error> type for operations that might fail:

type Result<'Success, 'Failure> =
    | Ok of 'Success
    | Error of 'Failure

This makes error handling explicit and composable:

type ValidationError =
    | InvalidEmail of string
    | InvalidAge of string
    | InvalidPostalCode of string

let validateCustomer firstName lastName email age =
    result {
        let! validEmail = EmailAddress.create email
        let! validAge = Age.create age
        return {
            FirstName = firstName
            LastName = lastName
            Email = validEmail
            Age = validAge
        }
    }

🔧 Try this: Notice how result { } computation expression lets you chain validations naturally. The let! operator unwraps Ok values and short-circuits on the first Error.

Detailed Examples 💻

Example 1: E-Commerce Product Pricing

Let's model product pricing with discounts:

// Step 1: Define constrained primitives
type Price = private Price of decimal

module Price =
    let create value =
        if value < 0m then
            Error "Price cannot be negative"
        elif value > 1_000_000m then
            Error "Price exceeds maximum"
        else
            Ok (Price value)
    
    let value (Price p) = p
    let multiply factor (Price p) = Price (p * factor)

// Step 2: Model discount types
type DiscountType =
    | Percentage of percent: decimal
    | FixedAmount of amount: decimal
    | BuyXGetYFree of buy: int * getFree: int
    | NoDiscount

// Step 3: Apply business logic
let applyDiscount discountType (price: Price) =
    match discountType with
    | NoDiscount -> price
    | Percentage pct when pct >= 0m && pct <= 100m ->
        Price.multiply (1m - pct / 100m) price
    | Percentage _ -> price  // Invalid percentage, no discount
    | FixedAmount amt ->
        let currentPrice = Price.value price
        let newPrice = max 0m (currentPrice - amt)
        Price.create newPrice |> Result.defaultValue price
    | BuyXGetYFree (buy, free) ->
        // Logic for quantity-based discounts
        price  // Simplified for example

// Usage
let originalPrice = Price.create 99.99m |> Result.get
let discounted = applyDiscount (Percentage 25m) originalPrice
// Result: $74.99

Key Takeaway: The type system prevents negative prices, invalid percentages, and impossible discount states. Business rules are encoded in types, not scattered in validation code.

Example 2: User Registration State Machine

Model user registration as a state machine:

// States
type UnverifiedUser = {
    UserId: UserId
    Email: EmailAddress
    PasswordHash: string
    VerificationToken: string
    TokenExpiry: System.DateTime
}

type VerifiedUser = {
    UserId: UserId
    Email: EmailAddress
    PasswordHash: string
    VerifiedAt: System.DateTime
}

type LockedUser = {
    UserId: UserId
    Email: EmailAddress
    PasswordHash: string
    LockedAt: System.DateTime
    Reason: string
}

type User =
    | Unverified of UnverifiedUser
    | Verified of VerifiedUser
    | Locked of LockedUser

// State transitions
let verifyUser (token: string) (user: User) : Result<User, string> =
    match user with
    | Unverified u when u.VerificationToken = token && u.TokenExpiry > System.DateTime.UtcNow ->
        Ok (Verified {
            UserId = u.UserId
            Email = u.Email
            PasswordHash = u.PasswordHash
            VerifiedAt = System.DateTime.UtcNow
        })
    | Unverified _ -> Error "Invalid or expired token"
    | Verified _ -> Error "User already verified"
    | Locked _ -> Error "User account is locked"

let lockUser (reason: string) (user: User) : Result<User, string> =
    match user with
    | Verified v | Unverified v ->
        Ok (Locked {
            UserId = v.UserId
            Email = v.Email
            PasswordHash = v.PasswordHash
            LockedAt = System.DateTime.UtcNow
            Reason = reason
        })
    | Locked _ -> Error "User already locked"

What makes this powerful:

  • An Unverified user must have a token and expiry
  • A Verified user must have a verification timestamp
  • You cannot accidentally access VerificationToken on a Verified user
  • State transitions are explicit functions with clear success/failure cases

Example 3: Shopping Cart with Quantity Constraints

// Quantity must be between 1 and 100
type Quantity = private Quantity of int

module Quantity =
    let create value =
        if value < 1 then Error "Quantity must be at least 1"
        elif value > 100 then Error "Maximum quantity is 100"
        else Ok (Quantity value)
    
    let value (Quantity q) = q
    
    let add (Quantity q1) (Quantity q2) =
        create (q1 + q2)

type ProductId = ProductId of System.Guid

type CartItem = {
    ProductId: ProductId
    Quantity: Quantity
    Price: Price
}

type EmptyCart = EmptyCart

type ActiveCart = {
    Items: Map<ProductId, CartItem>
    LastModified: System.DateTime
}

type CheckedOutCart = {
    Items: Map<ProductId, CartItem>
    TotalPrice: Price
    CheckedOutAt: System.DateTime
}

type ShoppingCart =
    | Empty of EmptyCart
    | Active of ActiveCart
    | CheckedOut of CheckedOutCart

let addToCart (productId: ProductId) (qty: Quantity) (price: Price) (cart: ShoppingCart) =
    match cart with
    | Empty _ ->
        Active {
            Items = Map.empty |> Map.add productId { ProductId = productId; Quantity = qty; Price = price }
            LastModified = System.DateTime.UtcNow
        }
    | Active ac ->
        let newItems =
            match Map.tryFind productId ac.Items with
            | Some existing ->
                match Quantity.add existing.Quantity qty with
                | Ok newQty ->
                    Map.add productId { existing with Quantity = newQty } ac.Items
                | Error _ ->
                    ac.Items  // Exceeded max, keep old quantity
            | None ->
                Map.add productId { ProductId = productId; Quantity = qty; Price = price } ac.Items
        Active { Items = newItems; LastModified = System.DateTime.UtcNow }
    | CheckedOut _ ->
        cart  // Cannot modify checked-out cart

Benefits:

  • Impossible to have a cart with zero or negative quantity
  • Cannot add items to a checked-out cart (returns cart unchanged)
  • Type system tracks cart lifecycle: Empty → Active → CheckedOut

Example 4: Contact Information with Multiple Channels

type EmailAddress = private EmailAddress of string
type PhoneNumber = private PhoneNumber of string
type PostalAddress = {
    Street: string
    City: string
    PostalCode: string
    Country: string
}

// User must have AT LEAST one contact method
type ContactMethod =
    | EmailOnly of EmailAddress
    | PhoneOnly of PhoneNumber
    | PostalOnly of PostalAddress
    | EmailAndPhone of EmailAddress * PhoneNumber
    | EmailAndPostal of EmailAddress * PostalAddress
    | PhoneAndPostal of PhoneNumber * PostalAddress
    | AllThree of EmailAddress * PhoneNumber * PostalAddress

// Smart constructor ensures at least one method exists
let createContactMethod 
    (email: EmailAddress option) 
    (phone: PhoneNumber option) 
    (postal: PostalAddress option) : Result<ContactMethod, string> =
    match email, phone, postal with
    | Some e, Some p, Some a -> Ok (AllThree (e, p, a))
    | Some e, Some p, None -> Ok (EmailAndPhone (e, p))
    | Some e, None, Some a -> Ok (EmailAndPostal (e, a))
    | None, Some p, Some a -> Ok (PhoneAndPostal (p, a))
    | Some e, None, None -> Ok (EmailOnly e)
    | None, Some p, None -> Ok (PhoneOnly p)
    | None, None, Some a -> Ok (PostalOnly a)
    | None, None, None -> Error "At least one contact method required"

// Extract all emails for notification
let getEmail (contact: ContactMethod) : EmailAddress option =
    match contact with
    | EmailOnly e | EmailAndPhone (e, _) | EmailAndPostal (e, _) | AllThree (e, _, _) -> Some e
    | PhoneOnly _ | PostalOnly _ | PhoneAndPostal _ -> None

🤔 Did you know? This pattern (ensuring at least one value exists) is called a "non-empty" constraint. It's impossible to create a ContactMethod with zero contact options—the type system enforces your business rule!

Common Mistakes ⚠️

Mistake 1: Not Making Constructors Private

// ❌ BAD: Anyone can create invalid emails
type EmailAddress = EmailAddress of string

let bad = EmailAddress "not-an-email"  // Compiles!
// ✅ GOOD: Private constructor forces validation
type EmailAddress = private EmailAddress of string

module EmailAddress =
    let create input =
        if input.Contains("@") then Ok (EmailAddress input)
        else Error "Invalid email"

Mistake 2: Using Primitive Types Everywhere

// ❌ BAD: Easy to mix up parameters
let processOrder (orderId: string) (customerId: string) (productId: string) = 
    // What if you swap orderId and customerId? Compiler won't catch it!
    ...
// ✅ GOOD: Impossible to mix up
type OrderId = OrderId of System.Guid
type CustomerId = CustomerId of System.Guid
type ProductId = ProductId of System.Guid

let processOrder (orderId: OrderId) (customerId: CustomerId) (productId: ProductId) =
    // Type-safe!
    ...

Mistake 3: Allowing Invalid States

// ❌ BAD: Can have shipped = true but trackingNumber = None
type Order = {
    Id: string
    IsShipped: bool
    TrackingNumber: string option
}
// ✅ GOOD: States are mutually exclusive
type Order =
    | NotShipped of orderId: string
    | Shipped of orderId: string * trackingNumber: string

Mistake 4: Forgetting to Unwrap Single-Case Unions

type Age = Age of int

let age = Age 25

// ❌ WRONG: Trying to use Age as int
let nextYear = age + 1  // ERROR!

// ✅ RIGHT: Pattern match to unwrap
let nextYear = 
    let (Age value) = age
    value + 1

// Or create a helper function
module Age =
    let value (Age a) = a
    
let nextYear = (Age.value age) + 1

Mistake 5: Not Handling All Cases in Pattern Matching

type PaymentStatus = Pending | Paid | Failed

let message status =
    match status with
    | Pending -> "Waiting for payment"
    | Paid -> "Payment received"
    // ❌ Forgot Failed case! Compiler warning!

💡 Always enable warnings as errors in F# projects to catch incomplete pattern matches!

Key Takeaways 🎯

  1. Types are documentation: A well-designed type system makes code self-explanatory
  2. Make illegal states unrepresentable: Use discriminated unions to model mutually exclusive states
  3. Wrap primitives: Use single-case unions with private constructors and smart constructors
  4. Validate at boundaries: Check constraints once when creating types, never again
  5. Composition over complexity: Build complex domains from simple, validated pieces
  6. Pattern matching is exhaustive: The compiler ensures you handle every case
  7. Result types for errors: Make success and failure explicit, avoid exceptions
  8. Domain types ≠ database types: Model your domain ideally; map to persistence separately

📋 Quick Reference Card

Product TypeRecord with AND relationshiptype Person = { Name: string; Age: int }
Sum TypeDiscriminated Union with ORtype Status = Active | Inactive
Single-Case UnionWrap primitive for type safetytype Email = Email of string
Smart ConstructorValidate on creationlet create x = if valid x then Ok (...) else Error (...)
Private ConstructorForce validation through moduletype Email = private Email of string
Result TypeExplicit success/failureResult<'T, 'Error>
Pattern MatchExhaustive case handlingmatch x with | Case1 -> ... | Case2 -> ...

📚 Further Study

Practice Questions

Test your understanding with these questions:

Q1: Complete the single-case union wrapper: ```fsharp type EmailAddress = {{1}} EmailAddress of string module EmailAddress = let create input = if input.Contains("@") then Ok (EmailAddress input) else Error "Invalid" ```
A: ["private"]
Q2: What does this domain model enforce? ```fsharp type Order = | Pending of items: Item list | Shipped of items: Item list * tracking: string | Cancelled ``` A. Orders can be shipped without items B. Shipped orders must have tracking numbers C. Orders can have multiple statuses simultaneously D. Tracking numbers are optional for all orders E. Cancelled orders must include items
A: B
Q3: Fill in the blank to unwrap the single-case union: ```fsharp type Age = Age of int let age = Age 30 let nextAge = ({{1}} value) age + 1 ```
A: Age.value
Q4: What is the output of this code? ```fsharp type PaymentMethod = | Cash | Card of number: string let payment = Card "1234" match payment with | Cash -> "No details" | Card num -> sprintf "Card: %s" num ``` A. "No details" B. "Card: 1234" C. "Card: Card" D. Compilation error E. "1234"
A: B
Q5: Complete the Result-based validation: ```fsharp type Price = private Price of decimal module Price = let create value = if value < 0m then {{1}} "Price cannot be negative" else {{2}} (Price value) ```
A: ["Error","Ok"]