Define shared domain types and API contracts for F# full-stack applications using records, discriminated unions, and Fable.Remoting interfaces. Use when starting new features, defining data...
In F#, types aren't just data containers—they're executable specifications. Well-designed types make illegal states unrepresentable and make the code self-documenting.
Before defining types, ask:
Core Principles:
Make Illegal States Unrepresentable: If it can't exist in the domain, it shouldn't compile. Use discriminated unions to constrain possibilities.
Types Are Documentation: A well-designed type tells you what you can do with it. If you need comments to explain, the type could be clearer.
Separate Inputs from Entities: Create request types separate from domain entities. The API caller shouldn't set server-managed fields.
Option Over Null: Use option for optional values. Null doesn't exist in our vocabulary.
Records are the foundation. They're immutable by default and have structural equality.
type Order = {
Id: int
CustomerId: int
Items: OrderItem list
Status: OrderStatus
CreatedAt: DateTime
ShippedAt: DateTime option // option for nullable
}
type OrderItem = {
ProductId: int
ProductName: string
Quantity: int
UnitPrice: decimal
}
Key decisions:
option for fields that may not have valueslist for collections (immutable, good for F#)string option option is a smellUnions model states, choices, and constrained sets. The compiler enforces exhaustive handling.
// Simple enumeration
type Priority = Low | Medium | High | Urgent
// State machine
type OrderStatus =
| Draft
| Submitted
| Processing
| Shipped of trackingNumber: string
| Delivered of deliveredAt: DateTime
| Cancelled of reason: string
// Choice type
type PaymentMethod =
| CreditCard of last4: string * expiry: string
| BankTransfer of accountNumber: string
| PayPal of email: string
Why unions?
For values with invariants (non-empty strings, positive numbers, valid emails), use private types with factory functions.
type EmailAddress = private EmailAddress of string
module EmailAddress =
let create (s: string) : Result<EmailAddress, string> =
if s.Contains("@") && s.Length >= 5 then
Ok (EmailAddress s)
else
Error "Invalid email format"
let value (EmailAddress s) = s
// Usage
match EmailAddress.create input with
| Ok email -> { User with Email = email } // EmailAddress is guaranteed valid
| Error msg -> // handle invalid input
When to use:
type IOrderApi = {
// Queries: always return data (empty list if none)
getAll: unit -> Async<Order list>
getByCustomer: int -> Async<Order list>
// Single item: may not exist
getById: int -> Async<Result<Order, string>>
// Commands: may fail
create: CreateOrderRequest -> Async<Result<Order, string>>
update: int * UpdateOrderRequest -> Async<Result<Order, string>>
delete: int -> Async<Result<unit, string>>
}
| Scenario | Return Type | Example |
|---|---|---|
| Collection query | Async<'T list> |
getAll: unit -> Async<Order list> |
| Single item lookup | Async<Result<'T, string>> |
getById: int -> Async<Result<Order, string>> |
| Create/Update | Async<Result<'T, string>> |
create: Request -> Async<Result<Order, string>> |
| Delete | Async<Result<unit, string>> |
delete: int -> Async<Result<unit, string>> |
| Void operation | Async<unit> |
Rare—prefer Result for traceability |
Philosophy: Queries that return collections succeed with empty list. Operations that can fail return Result.
// Don't make caller set Id, CreatedAt, etc.
type CreateOrderRequest = {
CustomerId: int
Items: CreateOrderItemRequest list
Notes: string option
}
type CreateOrderItemRequest = {
ProductId: int
Quantity: int
}
// For partial updates, use option fields
type UpdateOrderRequest = {
Notes: string option option // None = don't change, Some None = clear, Some (Some x) = set
Status: OrderStatus option // None = don't change
}
Alternative: Explicit update types
type UpdateOrderRequest =
| UpdateNotes of string option
| UpdateStatus of OrderStatus
| UpdateMultiple of notes: string option * status: OrderStatus
type Auditable = {
CreatedAt: DateTime
UpdatedAt: DateTime
CreatedBy: string option
UpdatedBy: string option
}
type Deletable = {
IsDeleted: bool
DeletedAt: DateTime option
}
// Or union approach (makes illegal states impossible)
type EntityState =
| Active
| Deleted of deletedAt: DateTime * deletedBy: string
type PageRequest = {
Page: int
PageSize: int
SortBy: string option
SortDirection: SortDirection option
}
type SortDirection = Ascending | Descending
type PagedResult<'T> = {
Items: 'T list
TotalCount: int
Page: int
PageSize: int
TotalPages: int
}
// API
type IProductApi = {
search: string * PageRequest -> Async<PagedResult<Product>>
}
type OrderError =
| OrderNotFound
| InvalidQuantity of min: int * max: int
| OutOfStock of productId: int
| PaymentFailed of reason: string
| ShippingUnavailable of region: string
type IOrderApi = {
create: CreateOrderRequest -> Async<Result<Order, OrderError>>
}
When to use typed errors:
❌ Classes for Domain Types
// BAD
type Order() =
member val Id = 0 with get, set
member val Items = [] with get, set
Why bad: Mutable, no structural equality, verbose. Better: Use records.
❌ Null Instead of Option
// BAD
type User = { Email: string; Phone: string } // null for no phone?
Why bad: Null is not an F# idiom. It causes NullReferenceExceptions.
Better: Phone: string option
❌ Stringly Typed Data
// BAD
type Order = { Status: string } // "pending", "shipped", etc.
Why bad: Typos compile, no exhaustive matching, no type safety.
Better: Status: OrderStatus with a union.
❌ Logic in Type Definitions
// BAD
type Order = {
Items: OrderItem list
member this.Total = this.Items |> List.sumBy (fun i -> i.Price * decimal i.Qty)
}
Why bad: Types should be data. Logic goes in modules.
Better: module Order = let total order = ...
❌ God Types
// BAD: One type for all scenarios
type Order = {
Id: int
DraftItems: OrderItem list option // for drafts
SubmittedAt: DateTime option // for submitted
ShippedTrackingNumber: string option // for shipped
DeliveredAt: DateTime option // for delivered
}
Why bad: Most fields optional, invariants not enforced. Better: Union of different order stages, each with its data.
Simple CRUD entities: Straightforward records, basic union for status.
Complex workflows: State machine unions with stage-specific data.
External integrations: DTOs that mirror external API shapes, separate from domain types.
Event sourcing: Event types as unions, state rebuilt from events.
The type design matches domain complexity. Don't over-engineer simple data; don't under-model complex state.
Before finalizing types, verify:
option used for nullable fields (no nulls)Status, Type, Data)list (not array unless needed)Types are the foundation. Every other layer—validation, domain logic, persistence, API, frontend—builds on types. Time spent on good type design pays dividends throughout the stack.
The goal: When you read the types, you understand the domain. When you compile, impossible states are impossible.
/docs/04-SHARED-TYPES.md - Detailed type design patterns/docs/09-QUICK-REFERENCE.md - Quick templates