| name | fsharp-shared |
| description | 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 structures, or creating API contracts shared between client and server.
Creates types in src/Shared/Domain.fs and API interfaces in src/Shared/Api.fs.
Ensures type safety across the stack with compile-time checking.
|
| allowed-tools | Read, Edit, Write, Grep, Glob |
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 option for 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
option for fields that may not have values
- Use
list for collections (immutable, good for F#)
- Avoid nested nullability—
string option option is 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:
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