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

Records & Value Objects

Define immutable records to model domain entities with structural equality and automatic property generation.

Records & Value Objects in F#

Master records and value objects in F# with free flashcards and spaced repetition practice. This lesson covers record type definitions, immutability patterns, structural equality, and value object design principlesβ€”essential concepts for building robust functional applications.

Welcome πŸ‘‹

Welcome to Records & Value Objects! If you've been working with F#, you've likely encountered scenarios where you need to model data that's more structured than a simple tuple but doesn't require the ceremony of a full class. Records are F#'s elegant solution to this problem, and when combined with value object design patterns, they become powerful tools for domain modeling.

In this lesson, we'll explore how records provide immutability by default, structural equality out of the box, and concise syntax for creating, copying, and pattern matching. You'll learn how to leverage these features to build value objectsβ€”domain primitives that enforce business rules and make invalid states unrepresentable. By the end, you'll understand why records are fundamental to F# development and how they promote correctness and maintainability in your code.

πŸ’‘ Pro tip: Records are one of F#'s killer features. Many developers who try F# cite records as a major reason they stay!

Core Concepts πŸ“š

What Are Records? 🎯

Records are immutable, lightweight data structures in F# that group named values together. Think of them as tuples with labelsβ€”they provide structure and readability without the boilerplate of traditional classes.

Here's the simplest possible record:

type Person = {
    FirstName: string
    LastName: string
    Age: int
}

That's it! No constructor code, no property boilerplate, no getter/setter logic. The F# compiler generates everything you need automatically.

🌍 Real-world analogy: A record is like a form with labeled fields. Each field has a name and a specific type of information that goes there. Once you fill out the form, it's permanentβ€”if you need to change something, you fill out a new form with the corrections.

Creating Record Instances πŸ”¨

Creating a record instance uses a natural, JSON-like syntax:

let john = {
    FirstName = "John"
    LastName = "Doe"
    Age = 30
}

The compiler ensures you provide all required fields and that each field has the correct type. If you forget a field or use the wrong type, you'll get a compile-time errorβ€”no runtime surprises!

πŸ’‘ Type inference advantage: If the record type is clear from context, you don't even need to specify it. F# is smart enough to figure it out.

Immutability: The Copy-and-Update Pattern πŸ”„

Records are immutable by defaultβ€”once created, their values cannot change. But what if you need a "modified" version? F# provides the elegant copy-and-update syntax:

let olderJohn = { john with Age = 31 }

This creates a new record with the same values as john, except Age is now 31. The original john remains unchanged.

ORIGINAL              COPY-AND-UPDATE
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”          β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ john     β”‚          β”‚olderJohn β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€   with   β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚First: Johnβ”‚  Age=31  β”‚First: Johnβ”‚
β”‚Last: Doe β”‚  ──────→ β”‚Last: Doe β”‚
β”‚Age: 30   β”‚          β”‚Age: 31   β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜          β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
   (unchanged)         (new record)

🧠 Memory tip: Think "with" as "just like this one, but WITH this change."

Structural Equality: Value-Based Comparison βš–οΈ

One of the most powerful features of records is structural equality. Two record instances are equal if all their fields have equal values:

let person1 = { FirstName = "Jane"; LastName = "Smith"; Age = 25 }
let person2 = { FirstName = "Jane"; LastName = "Smith"; Age = 25 }

person1 = person2  // true!

This is fundamentally different from reference types in C# or Java, where equality typically checks if two variables point to the same object in memory. With records, equality is based on what they contain, not where they are.

FeatureRecords (F#)Classes (C#)
EqualityStructural (by value)Reference (by identity)
ImmutabilityDefaultOpt-in
ToStringAuto-generatedManual override needed
GetHashCodeAuto-generatedManual override needed

πŸ€” Did you know? Structural equality makes records perfect for dictionary keys and set elements without any extra work!

Value Objects: Domain Modeling Excellence πŸ’Ž

A value object is a domain concept that's defined by its attributes rather than an identity. In domain-driven design (DDD), value objects:

  • Are immutable
  • Have no identity (two instances with the same values are considered the same)
  • Encapsulate validation rules and business logic
  • Make invalid states unrepresentable

Records are perfect for implementing value objects! Here's a practical example:

type EmailAddress = private {
    Value: string
}
with
    static member Create(email: string) =
        if email.Contains("@") && email.Length > 3 then
            Some { Value = email }
        else
            None

Notice the private keyword after the type nameβ€”this prevents direct construction. Users must call EmailAddress.Create, which validates the input:

match EmailAddress.Create("test@example.com") with
| Some email -> printfn "Valid email: %s" email.Value
| None -> printfn "Invalid email format"

πŸ’‘ Key insight: By making the constructor private and forcing validation through a factory function, you ensure that every EmailAddress instance in your system is guaranteed valid. No defensive checks needed elsewhere!

Pattern Matching with Records 🎯

Records integrate beautifully with F#'s pattern matching:

let greet person =
    match person with
    | { Age = age } when age < 18 -> "Hello, young person!"
    | { FirstName = "John" } -> "Hello, John!"
    | { Age = age } when age >= 65 -> "Hello, senior!"
    | _ -> "Hello there!"

You can even destructure records directly in function parameters:

let getFullName { FirstName = first; LastName = last } =
    sprintf "%s %s" first last

let name = getFullName john  // "John Doe"

Nested Records and Composition πŸ—οΈ

Records can contain other records, enabling rich domain models:

type Address = {
    Street: string
    City: string
    ZipCode: string
}

type Customer = {
    Name: string
    Email: EmailAddress
    ShippingAddress: Address
    BillingAddress: Address
}

Updating nested records requires chaining the with syntax:

let updatedCustomer = 
    { customer with 
        ShippingAddress = { customer.ShippingAddress with City = "Boston" } 
    }

πŸ”§ Try this: For deeply nested updates, consider using a lens library like Aether or FSharpPlus to simplify the syntax.

Records vs. Classes vs. Tuples πŸ“Š

When should you use each?

Use CaseBest ChoiceWhy?
Domain entities with identityClassesNeed reference equality and mutable state
Value objects, DTOsRecordsStructural equality, immutability, clean syntax
Temporary grouping (2-3 values)TuplesNo need for named fields
Complex domain logicRecords + modulesData separate from behavior (functional style)

🧠 Rule of thumb: Default to records for data modeling. Use classes only when you need OOP features like inheritance or mutable state.

Practical Examples πŸ’»

Example 1: Building a Money Value Object πŸ’°

Let's create a robust Money type that enforces business rules:

type Currency = USD | EUR | GBP | JPY

type Money = private {
    Amount: decimal
    Currency: Currency
}
with
    static member Create(amount, currency) =
        if amount >= 0m then
            Some { Amount = amount; Currency = currency }
        else
            None
    
    static member (+) (m1: Money, m2: Money) =
        if m1.Currency <> m2.Currency then
            failwith "Cannot add money in different currencies"
        { Amount = m1.Amount + m2.Amount; Currency = m1.Currency }
    
    member this.Format() =
        match this.Currency with
        | USD -> sprintf "$%.2f" this.Amount
        | EUR -> sprintf "€%.2f" this.Amount
        | GBP -> sprintf "Β£%.2f" this.Amount
        | JPY -> sprintf "Β₯%.0f" this.Amount

What makes this a good value object?

  • βœ… Immutable: Once created, values can't change
  • βœ… Validated: Negative amounts are rejected at creation
  • βœ… Encapsulated logic: Currency formatting is built-in
  • βœ… Type-safe operations: Can't accidentally add different currencies
  • βœ… Self-documenting: The type system explains the domain

Usage:

let price1 = Money.Create(19.99m, USD)
let price2 = Money.Create(5.00m, USD)

match (price1, price2) with
| (Some p1, Some p2) -> 
    let total = p1 + p2
    printfn "Total: %s" (total.Format())  // "Total: $24.99"
| _ -> printfn "Invalid amounts"

Example 2: Modeling a Shopping Cart πŸ›’

Here's how records enable clear domain modeling:

type ProductId = ProductId of int
type Quantity = private Quantity of int
with
    static member Create(qty: int) =
        if qty > 0 && qty <= 100 then
            Some (Quantity qty)
        else
            None
    member this.Value = let (Quantity v) = this in v

type LineItem = {
    ProductId: ProductId
    ProductName: string
    UnitPrice: Money
    Quantity: Quantity
}
with
    member this.Total =
        let qty = this.Quantity.Value
        { this.UnitPrice with Amount = this.UnitPrice.Amount * decimal qty }

type Cart = {
    Items: LineItem list
    CustomerId: string
}
with
    member this.Total =
        this.Items
        |> List.map (fun item -> item.Total)
        |> List.reduce (+)
    
    member this.AddItem(item: LineItem) =
        { this with Items = item :: this.Items }
    
    member this.RemoveItem(productId: ProductId) =
        { this with Items = this.Items |> List.filter (fun i -> i.ProductId <> productId) }

Key patterns demonstrated:

  • Single-case unions (ProductId, Quantity): Prevent mixing up primitive types
  • Private constructors: Force validation through Create functions
  • Computed properties: Total calculated on-demand, not stored
  • Immutable updates: AddItem and RemoveItem return new carts

Example 3: Temporal Value Objects (Dates & Ranges) πŸ“…

Value objects shine when modeling temporal concepts:

open System

type DateRange = private {
    Start: DateTime
    End: DateTime
}
with
    static member Create(start: DateTime, endDate: DateTime) =
        if start <= endDate then
            Some { Start = start; End = endDate }
        else
            None
    
    member this.Duration = this.End - this.Start
    
    member this.Contains(date: DateTime) =
        date >= this.Start && date <= this.End
    
    member this.Overlaps(other: DateRange) =
        this.Start <= other.End && other.Start <= this.End

type Booking = {
    Id: Guid
    RoomNumber: int
    GuestName: string
    Period: DateRange
    CheckedIn: bool
}

Usage showing domain logic:

let hasConflict (existingBookings: Booking list) (newBooking: Booking) =
    existingBookings
    |> List.exists (fun b -> 
        b.RoomNumber = newBooking.RoomNumber && 
        b.Period.Overlaps(newBooking.Period))

Example 4: Records in Web APIs (DTOs) 🌐

Records make excellent Data Transfer Objects:

// Request DTO
type CreateUserRequest = {
    Username: string
    Email: string
    Password: string
}

// Response DTO
type UserResponse = {
    Id: Guid
    Username: string
    Email: string
    CreatedAt: DateTime
}

// Domain model (richer, validated)
type User = private {
    Id: Guid
    Username: string
    Email: EmailAddress
    PasswordHash: string
    CreatedAt: DateTime
}
with
    static member Create(id, username, email, passwordHash, createdAt) =
        { Id = id; Username = username; Email = email; 
          PasswordHash = passwordHash; CreatedAt = createdAt }
    
    member this.ToResponse() = {
        Id = this.Id
        Username = this.Username
        Email = this.Email.Value
        CreatedAt = this.CreatedAt
    }

This approach:

  • βœ… Keeps validated domain models separate from DTOs
  • βœ… Prevents exposing sensitive fields (password hash)
  • βœ… Allows different validation rules for internal vs. external data

Common Mistakes ⚠️

Mistake 1: Forgetting All Fields in Construction ❌

// WRONG: Compilation error - missing Age field
let person = {
    FirstName = "Alice"
    LastName = "Johnson"
}

βœ… Fix: Always provide all required fields. The compiler will catch this, but it's a common beginner stumble.

Mistake 2: Trying to Mutate Record Fields ❌

// WRONG: Records are immutable!
let mutable john = { FirstName = "John"; LastName = "Doe"; Age = 30 }
john.Age <- 31  // Compilation error!

βœ… Fix: Use the copy-and-update syntax:

let john = { FirstName = "John"; LastName = "Doe"; Age = 30 }
let olderJohn = { john with Age = 31 }

Mistake 3: Not Using Private Constructors for Value Objects ❌

// BAD: Anyone can create invalid email addresses
type EmailAddress = {
    Value: string
}

let bad = { Value = "not-an-email" }  // Compiles but invalid!

βœ… Fix: Make the constructor private and provide a validated factory:

type EmailAddress = private { Value: string }
with
    static member Create(email: string) =
        if email.Contains("@") then Some { Value = email }
        else None

Mistake 4: Deep Nesting Without Helper Functions ❌

// HARD TO READ: Nested updates become unwieldy
let updated = 
    { customer with 
        ShippingAddress = 
            { customer.ShippingAddress with 
                Location = 
                    { customer.ShippingAddress.Location with 
                        Coordinates = newCoords } } }

βœ… Fix: Create focused update functions:

module Customer =
    let updateShippingCoordinates coords customer =
        { customer with 
            ShippingAddress = 
                { customer.ShippingAddress with
                    Location = { customer.ShippingAddress.Location with Coordinates = coords } } }

let updated = Customer.updateShippingCoordinates newCoords customer

Mistake 5: Using Mutable Fields Inside Records ❌

// ANTI-PATTERN: Defeats the purpose of immutability
type BadRecord = {
    mutable Counter: int
    Name: string
}

βœ… Fix: Keep records fully immutable. If you need mutability, use a class or a ref cell stored in the record.

Mistake 6: Ignoring Validation at Domain Boundaries ❌

// RISKY: Assuming external data is valid
let processOrder (orderDto: OrderDto) =
    let price = { Amount = orderDto.Amount; Currency = USD }  // What if negative?
    // ... rest of logic

βœ… Fix: Always validate at boundaries:

let processOrder (orderDto: OrderDto) =
    match Money.Create(orderDto.Amount, USD) with
    | Some price -> // ... safe to proceed
    | None -> Error "Invalid amount"

Key Takeaways 🎯

πŸ“‹ Quick Reference Card: F# Records & Value Objects

Record Definition type Person = { Name: string; Age: int }
Create Instance let p = { Name = "Alice"; Age = 30 }
Copy-and-Update let older = { p with Age = 31 }
Structural Equality { Name = "Bob" } = { Name = "Bob" } // true
Private Constructor type Email = private { Value: string }
Validation Factory static member Create(v) = if valid v then Some {Value=v} else None
Pattern Match match person with | { Age = a } when a < 18 -> ...
Destructure let greet { Name = n } = sprintf "Hi, %s" n

Value Object Checklist:

  • βœ… Immutable (no mutable fields)
  • βœ… Validated at creation (private constructor + factory)
  • βœ… Structural equality (automatic with records)
  • βœ… No identity (defined by values, not reference)
  • βœ… Encapsulates domain logic (methods on the type)

When to Use Records:

  • 🎯 Domain value objects (Money, Email, Address)
  • 🎯 Data transfer objects (API requests/responses)
  • 🎯 Configuration objects
  • 🎯 Any data structure where equality by value makes sense

When NOT to Use Records:

  • ❌ Need mutable state (use classes)
  • ❌ Need inheritance (use classes)
  • ❌ Need reference equality (use classes)
  • ❌ Just 2-3 unnamed values (use tuples)

Further Study πŸ“š

  1. F# for Fun and Profit - Understanding Equality: https://fsharpforfunandprofit.com/posts/equality/
  2. Microsoft Docs - Records (F#): https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/records
  3. Domain Modeling Made Functional by Scott Wlaschin: https://pragprog.com/titles/swdddf/domain-modeling-made-functional/

πŸŽ‰ Congratulations! You now understand how F# records enable clean, safe domain modeling through immutability, structural equality, and value object patterns. Practice creating your own validated value objects and watch your code become more robust and expressive!

Practice Questions

Test your understanding with these questions:

Q1: What keyword creates a new record with modified fields? ```fsharp let updated = { original {{1}} Age = 30 } ```
A: with
Q2: Complete the record type definition: ```fsharp type Person = {{1}} { Name: {{2}} Age: int } ```
A: ["=","string"]
Q3: What does this code print? ```fsharp let p1 = { Name = "Alice"; Age = 25 } let p2 = { Name = "Alice"; Age = 25 } printfn "%b" (p1 = p2) ``` A. false B. true C. Compilation error D. Runtime error E. null
A: B
Q4: What visibility modifier prevents direct record construction? ```fsharp type Email = {{1}} { Value: string } ```
A: private
Q5: Complete the validation factory pattern: ```fsharp type Email = private { Value: string } with {{1}} member Create(email: string) = if email.Contains("@") then {{2}} { Value = email } else {{3}} ```
A: ["static","Some","None"]