fsharp-shared
F# Shared Types and API Contracts
Philosophy: Types as Executable Specification
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:
- What concepts exist in this domain?
- What states can entities be in?
- What operations are valid for each state?
- What can go wrong? (Model errors as types, not strings)
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
optionfor optional values. Null doesn't exist in our vocabulary.
Type Design Patterns
Records: Structured Data
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:
- Use
optionfor fields that may not have values - Use
listfor collections (immutable, good for F#) - Avoid nested nullability—
string option optionis a smell
Discriminated Unions: State Machines
Unions 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?
- Pattern matching is exhaustive—you can't forget a case
- Adding a case breaks all handlers—the compiler guides updates
- State-specific data is co-located with the state
Smart Constructors: Constrained Values
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:
- Email addresses, phone numbers (format constraints)
- Positive integers, non-empty strings (value constraints)
- Domain identifiers (type safety)
API Contract Patterns
Basic CRUD Interface
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>>
}
Return Type Guide
| 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.
Request Types: Separate from Entities
// 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
Common Domain Patterns
Timestamps
type Auditable = {
CreatedAt: DateTime
UpdatedAt: DateTime
CreatedBy: string option
UpdatedBy: string option
}
Soft Delete
type Deletable = {
IsDeleted: bool
DeletedAt: DateTime option
}
// Or union approach (makes illegal states impossible)
type EntityState =
| Active
| Deleted of deletedAt: DateTime * deletedBy: string
Pagination
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>>
}
Error Types (Beyond Strings)
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:
- Client needs to handle different errors differently
- Domain has specific failure modes worth modeling
- You want exhaustive error handling
Anti-Patterns to Avoid
❌ 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.
Variation Guidance
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.
Type Design Checklist
Before finalizing types, verify:
- Records used for data, unions for choices/states
-
optionused for nullable fields (no nulls) - Request types separate from entities
- Server-managed fields not in request types
- Meaningful names (not
Status,Type,Data) - Collection fields use
list(notarrayunless needed) - No logic in type definitions
- No impossible states representable
Remember
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.
Related Documentation
/docs/04-SHARED-TYPES.md- Detailed type design patterns/docs/09-QUICK-REFERENCE.md- Quick templates