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.
| Feature | Records (F#) | Classes (C#) |
|---|---|---|
| Equality | Structural (by value) | Reference (by identity) |
| Immutability | Default | Opt-in |
| ToString | Auto-generated | Manual override needed |
| GetHashCode | Auto-generated | Manual 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 Case | Best Choice | Why? |
|---|---|---|
| Domain entities with identity | Classes | Need reference equality and mutable state |
| Value objects, DTOs | Records | Structural equality, immutability, clean syntax |
| Temporary grouping (2-3 values) | Tuples | No need for named fields |
| Complex domain logic | Records + modules | Data 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
Createfunctions - Computed properties:
Totalcalculated on-demand, not stored - Immutable updates:
AddItemandRemoveItemreturn 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 π
- F# for Fun and Profit - Understanding Equality: https://fsharpforfunandprofit.com/posts/equality/
- Microsoft Docs - Records (F#): https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/records
- 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!