| name | preferences-algebraic-data-types |
| description | Algebraic data type patterns including sum types, product types, and pattern matching across languages. Load when designing type hierarchies or working with discriminated unions. |
Algebraic data types
Overview
Algebraic data types (ADTs) are types formed by combining other types using two operations: sum (OR) and product (AND).
This algebraic approach enables precise modeling of domain concepts and makes illegal states unrepresentable.
Sum types (discriminated unions)
Model "OR" relationships - a value is one of several possible variants.
Pattern: Enum types for closed value sets
Use PostgreSQL ENUMs when the set of valid values is fixed and known.
CREATE TYPE order_status AS ENUM (
'pending',
'confirmed',
'shipped',
'delivered',
'cancelled'
);
CREATE TABLE orders (
id UUID PRIMARY KEY,
status order_status NOT NULL DEFAULT 'pending',
metadata JSONB NOT NULL DEFAULT '{}'
);
ALTER TABLE orders ADD CONSTRAINT shipped_has_tracking CHECK (
status != 'shipped' OR (metadata ? 'tracking_number')
);
ALTER TABLE orders ADD CONSTRAINT cancelled_has_reason CHECK (
status != 'cancelled' OR (metadata ? 'cancellation_reason')
);
DuckDB compatibility:
DuckDB doesn't support ENUM types - use VARCHAR with CHECK constraints:
CREATE TABLE orders (
status VARCHAR NOT NULL,
CHECK (status IN ('pending', 'confirmed', 'shipped', 'delivered', 'cancelled'))
);
ENUM evolution:
ALTER TYPE order_status ADD VALUE 'refunded';
Pattern: CHECK constraints for inline sum types
When ENUM feels heavyweight or values may evolve:
CREATE TABLE events (
id UUID PRIMARY KEY,
event_type VARCHAR NOT NULL,
payload JSONB NOT NULL,
CHECK (event_type IN ('UserCreated', 'UserUpdated', 'UserDeleted'))
);
Pattern: Tagged unions with JSONB
For sum types where each variant has a different shape:
CREATE TABLE events (
id UUID PRIMARY KEY,
event_type VARCHAR NOT NULL,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE events ADD CONSTRAINT user_created_shape CHECK (
event_type != 'UserCreated' OR (
jsonb_typeof(payload) = 'object' AND
(payload ? 'user_id') AND
(payload ? 'email') AND
(payload ? 'name')
)
);
ALTER TABLE events ADD CONSTRAINT user_updated_shape CHECK (
event_type != 'UserUpdated' OR (
jsonb_typeof(payload) = 'object' AND
(payload ? 'user_id') AND
(payload ? 'changes')
)
);
ALTER TABLE events ADD CONSTRAINT user_deleted_shape CHECK (
event_type != 'UserDeleted' OR (
jsonb_typeof(payload) = 'object' AND
(payload ? 'user_id')
)
);
ALTER TABLE events ADD CONSTRAINT valid_event_type CHECK (
event_type IN ('UserCreated', 'UserUpdated', 'UserDeleted')
);
Cross-language sum types
Python: Discriminated unions with Pydantic
from typing import Union, Literal
from pydantic import BaseModel
from uuid import UUID
class Pending(BaseModel):
type: Literal["pending"]
class Confirmed(BaseModel):
type: Literal["confirmed"]
confirmed_at: datetime
class Shipped(BaseModel):
type: Literal["shipped"]
tracking_number: str
shipped_at: datetime
class Delivered(BaseModel):
type: Literal["delivered"]
delivered_at: datetime
class Cancelled(BaseModel):
type: Literal["cancelled"]
reason: str
cancelled_at: datetime
OrderStatus = Union[Pending, Confirmed, Shipped, Delivered, Cancelled]
class Order(BaseModel):
id: UUID
status: OrderStatus
def processOrder(order: Order) -> str:
match order.status:
case Pending():
return "Processing payment..."
case Confirmed(confirmed_at=dt):
return f"Confirmed at {dt}"
case Shipped(tracking_number=num):
return f"Tracking: {num}"
case Delivered(delivered_at=dt):
return f"Delivered at {dt}"
case Cancelled(reason=r):
return f"Cancelled: {r}"
TypeScript: Discriminated unions
type OrderStatus =
| { type: "pending" }
| { type: "confirmed"; confirmedAt: Date }
| { type: "shipped"; trackingNumber: string; shippedAt: Date }
| { type: "delivered"; deliveredAt: Date }
| { type: "cancelled"; reason: string; cancelledAt: Date };
interface Order {
id: string;
status: OrderStatus;
}
function processOrder(order: Order): string {
switch (order.status.type) {
case "pending":
return "Processing payment...";
case "confirmed":
return `Confirmed at ${order.status.confirmedAt}`;
case "shipped":
return `Tracking: ${order.status.trackingNumber}`;
case "delivered":
return `Delivered at ${order.status.deliveredAt}`;
case "cancelled":
return `Cancelled: ${order.status.reason}`;
}
}
Go: Sealed interfaces
type OrderStatus interface {
isOrderStatus()
}
type Pending struct{}
type Confirmed struct {
ConfirmedAt time.Time
}
type Shipped struct {
TrackingNumber string
ShippedAt time.Time
}
type Delivered struct {
DeliveredAt time.Time
}
type Cancelled struct {
Reason string
CancelledAt time.Time
}
func (Pending) isOrderStatus() {}
func (Confirmed) isOrderStatus() {}
func (Shipped) isOrderStatus() {}
func (Delivered) isOrderStatus() {}
func (Cancelled) isOrderStatus() {}
func processOrder(status OrderStatus) string {
switch s := status.(type) {
case Pending:
return "Processing payment..."
case Confirmed:
return fmt.Sprintf("Confirmed at %v", s.ConfirmedAt)
case Shipped:
return fmt.Sprintf("Tracking: %s", s.TrackingNumber)
case Delivered:
return fmt.Sprintf("Delivered at %v", s.DeliveredAt)
case Cancelled:
return fmt.Sprintf("Cancelled: %s", s.Reason)
default:
return "Unknown status"
}
}
Product types
Model "AND" relationships - a value contains all fields together.
Product types are already implicit in your schema design: tables and records.
class User(BaseModel):
id: UUID
email: str
created_at: datetime
CREATE TABLE users (
id UUID,
email VARCHAR,
created_at TIMESTAMPTZ
);
Pattern: Composite types for temporary values
CREATE TYPE coordinates AS (
x INTEGER,
y INTEGER
);
CREATE TYPE address AS (
street VARCHAR,
city VARCHAR,
state VARCHAR,
zip VARCHAR
);
Coordinates = tuple[int, int]
from typing import NamedTuple
class Coordinates(NamedTuple):
x: int
y: int
Algebraic properties
The name "algebraic data types" reflects that sum and product types satisfy algebraic laws analogous to arithmetic.
Understanding these properties enables reasoning about type equivalences and refactoring.
Sum types as addition
Sum types correspond to addition (coproduct in category theory).
The number of inhabitants of A | B equals the sum of inhabitants of A and B.
Laws:
- Commutativity:
A | B ≅ B | A (order doesn't matter)
- Associativity:
(A | B) | C ≅ A | (B | C) (grouping doesn't matter)
- Identity:
A | Never ≅ A (Never/Void is the zero element)
Product types as multiplication
Product types correspond to multiplication (categorical product).
The number of inhabitants of (A, B) equals the product of inhabitants of A and B.
Laws:
- Commutativity:
(A, B) ≅ (B, A)
- Associativity:
((A, B), C) ≅ (A, (B, C))
- Identity:
(A, ()) ≅ A (unit type is the identity)
Distributivity
Products distribute over sums, just like multiplication over addition.
A × (B + C) ≅ (A × B) + (A × C)
This equivalence enables refactoring between representations:
struct Tagged<A, B> {
tag: String,
value: Either<A, B>,
}
enum Tagged<A, B> {
Left { tag: String, value: A },
Right { tag: String, value: B },
}
Exponentials as functions
Function types correspond to exponentiation.
The type A -> B has |B|^|A| inhabitants.
This completes the arithmetic correspondence:
0 = Never/Void (uninhabited)
1 = () / Unit (single inhabitant)
+ = Sum types
× = Product types
^ = Function types
See preferences-theoretical-foundations references/internal-language.md#universal-properties-the-initialfinal-duality for the categorical perspective on these constructions as universal properties.
Recursive types and initial algebras
Recursive ADTs like lists and trees are initial algebras of their defining functor.
The fold operation (catamorphism) is the unique homomorphism from this initial algebra.
-- List as initial algebra of ListF
data ListF a r = Nil | Cons a r
-- Natural numbers as initial algebra of NatF
data NatF r = Zero | Succ r
For the full categorical treatment of ADTs as initial algebras, including catamorphisms, see preferences-theoretical-foundations references/decide-evolve-lens.md#f-algebras-and-catamorphisms-briefly, where evolve is read as an F-algebra and state reconstruction as the catamorphism that folds it.
Newtype pattern
Wrap primitive types to prevent mixing semantically different values.
Pattern: DOMAIN types in PostgreSQL
CREATE DOMAIN email_address AS VARCHAR
CHECK (VALUE ~ '^[^@]+@[^@]+\.[^@]+$');
CREATE DOMAIN positive_int AS INTEGER
CHECK (VALUE > 0);
CREATE DOMAIN url AS TEXT
CHECK (VALUE ~ '^https?://');
CREATE DOMAIN percentage AS NUMERIC(5,2)
CHECK (VALUE >= 0 AND VALUE <= 100);
CREATE TABLE users (
id UUID PRIMARY KEY,
email email_address NOT NULL,
age positive_int,
website url,
completion_rate percentage
);
DuckDB compatibility:
DuckDB doesn't support DOMAIN - use inline CHECK constraints:
CREATE TABLE users (
id UUID PRIMARY KEY,
email VARCHAR NOT NULL CHECK (email ~ '^[^@]+@[^@]+\.[^@]+$'),
age INTEGER CHECK (age > 0),
website TEXT CHECK (website ~ '^https?://'),
completion_rate NUMERIC(5,2) CHECK (completion_rate BETWEEN 0 AND 100)
);
Pattern: Wrapper types in application code
Python: Single-field Pydantic models
from pydantic import BaseModel, validator
from uuid import UUID
class UserId(BaseModel):
value: UUID
class OrderId(BaseModel):
value: UUID
class ProductId(BaseModel):
value: UUID
class EmailAddress(BaseModel):
value: str
@validator('value')
def must_be_valid(cls, v):
if '@' not in v or '.' not in v.split('@')[1]:
raise ValueError('invalid email format')
return v.lower().strip()
class PhoneNumber(BaseModel):
value: str
@validator('value')
def must_be_valid(cls, v):
digits = ''.join(c for c in v if c.isdigit())
if len(digits) != 10:
raise ValueError('must be 10 digits')
return digits
class Dollars(BaseModel):
value: Decimal
@validator('value')
def must_be_non_negative(cls, v):
if v < 0:
raise ValueError('cannot be negative')
return v
def getUser(id: UserId) -> User:
...
def chargeCard(amount: Dollars) -> PaymentResult:
...
TypeScript: Branded types
type Brand<K, T> = K & { __brand: T };
type UserId = Brand<string, "UserId">;
type OrderId = Brand<string, "OrderId">;
type EmailAddress = Brand<string, "EmailAddress">;
type Dollars = Brand<number, "Dollars">;
function makeUserId(s: string): UserId | Error {
if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s)) {
return new Error("invalid UUID");
}
return s as UserId;
}
function makeEmailAddress(s: string): EmailAddress | Error {
if (!s.includes("@") || !s.split("@")[1].includes(".")) {
return new Error("invalid email");
}
return s.toLowerCase().trim() as EmailAddress;
}
function makeDollars(n: number): Dollars | Error {
if (n < 0) {
return new Error("cannot be negative");
}
return n as Dollars;
}
function getUser(id: UserId): User {
}
Go: Struct wrappers
type UserId struct {
value uuid.UUID
}
func NewUserId(id uuid.UUID) UserId {
return UserId{value: id}
}
func (u UserId) String() string {
return u.value.String()
}
type EmailAddress struct {
value string
}
func NewEmailAddress(s string) (EmailAddress, error) {
if !strings.Contains(s, "@") || !strings.Contains(strings.Split(s, "@")[1], ".") {
return EmailAddress{}, errors.New("invalid email format")
}
normalized := strings.ToLower(strings.TrimSpace(s))
return EmailAddress{value: normalized}, nil
}
func (e EmailAddress) String() string {
return e.value
}
type Dollars struct {
value decimal.Decimal
}
func NewDollars(amount decimal.Decimal) (Dollars, error) {
if amount.LessThan(decimal.Zero) {
return Dollars{}, errors.New("cannot be negative")
}
return Dollars{value: amount}, nil
}
func GetUser(id UserId) (*User, error) {
}
When to use newtypes
Always wrap primitives when:
- IDs that should never be mixed: UserId vs OrderId vs ProductId
- Validated strings: EmailAddress, PhoneNumber, URL, SSN
- Quantities with units: Meters, Dollars, Seconds, Bytes
- Opaque tokens: ApiKey, SessionToken, PasswordHash
- Constrained values: PositiveInt, NonEmptyString, Percentage
Making illegal states unrepresentable
Design types so invalid combinations cannot be constructed.
Anti-pattern: Boolean flags with dependent fields
CREATE TABLE reservations (
id UUID PRIMARY KEY,
confirmed BOOLEAN NOT NULL,
cancelled BOOLEAN NOT NULL,
confirmation_date TIMESTAMPTZ,
cancellation_reason TEXT
);
Pattern: Sum types for mutually exclusive states
CREATE TYPE reservation_status AS ENUM ('pending', 'confirmed', 'cancelled');
CREATE TABLE reservations (
id UUID PRIMARY KEY,
status reservation_status NOT NULL,
metadata JSONB NOT NULL DEFAULT '{}'
);
ALTER TABLE reservations ADD CONSTRAINT confirmed_has_date CHECK (
status != 'confirmed' OR (
jsonb_typeof(metadata->'confirmation_date') = 'string'
)
);
ALTER TABLE reservations ADD CONSTRAINT cancelled_has_reason CHECK (
status != 'cancelled' OR (
jsonb_typeof(metadata->'cancellation_reason') = 'string'
)
);
Generated code enforces at type level:
class Pending(BaseModel):
type: Literal["pending"]
class Confirmed(BaseModel):
type: Literal["confirmed"]
confirmation_date: datetime
class Cancelled(BaseModel):
type: Literal["cancelled"]
cancellation_reason: str
ReservationStatus = Union[Pending, Confirmed, Cancelled]
class Reservation(BaseModel):
id: UUID
status: ReservationStatus
Anti-pattern: Optional fields that should be mutually exclusive
CREATE TABLE payments (
id UUID PRIMARY KEY,
credit_card_token VARCHAR,
bank_account_number VARCHAR,
amount NUMERIC NOT NULL
);
Pattern: Sum type for payment methods
CREATE TYPE payment_method_type AS ENUM ('credit_card', 'bank_account', 'paypal');
CREATE TABLE payments (
id UUID PRIMARY KEY,
payment_method_type payment_method_type NOT NULL,
payment_details JSONB NOT NULL,
amount NUMERIC NOT NULL,
CHECK (
(payment_method_type = 'credit_card' AND payment_details ? 'card_token') OR
(payment_method_type = 'bank_account' AND payment_details ? 'account_number') OR
(payment_method_type = 'paypal' AND payment_details ? 'paypal_email')
)
);
Generated code:
class CreditCardPayment(BaseModel):
type: Literal["credit_card"]
card_token: str
class BankAccountPayment(BaseModel):
type: Literal["bank_account"]
account_number: str
routing_number: str
class PaypalPayment(BaseModel):
type: Literal["paypal"]
paypal_email: str
PaymentMethod = Union[CreditCardPayment, BankAccountPayment, PaypalPayment]
class Payment(BaseModel):
id: UUID
payment_method: PaymentMethod
amount: Decimal
State machines with sum types
Sum types naturally model state machines where entities transition through discrete states.
Pattern: Shopping cart states
from typing import Literal, Union
from pydantic import BaseModel
from datetime import datetime
from decimal import Decimal
class EmptyCart(BaseModel):
type: Literal["empty"]
session_id: str
class ActiveCart(BaseModel):
type: Literal["active"]
session_id: str
items: list[CartItem]
subtotal: Decimal
class PaidCart(BaseModel):
type: Literal["paid"]
session_id: str
items: list[CartItem]
total: Decimal
payment_id: str
paid_at: datetime
CartState = EmptyCart | ActiveCart | PaidCart
def add_item(cart: EmptyCart | ActiveCart, item: CartItem) -> ActiveCart:
"""Adding item always results in ActiveCart."""
items = cart.items if isinstance(cart, ActiveCart) else []
new_items = items + [item]
subtotal = sum(i.price * i.quantity for i in new_items)
return ActiveCart(
type="active",
session_id=cart.session_id,
items=new_items,
subtotal=subtotal
)
def checkout(cart: ActiveCart, payment_id: str) -> PaidCart:
"""Can only checkout ActiveCart, produces PaidCart."""
return PaidCart(
type="paid",
session_id=cart.session_id,
items=cart.items,
total=cart.subtotal,
payment_id=payment_id,
paid_at=datetime.now()
)
Pattern: Email verification workflow
class UnverifiedEmail(BaseModel):
type: Literal["unverified"]
email: str
verification_token: str
sent_at: datetime
class VerifiedEmail(BaseModel):
type: Literal["verified"]
email: str
verified_at: datetime
EmailState = UnverifiedEmail | VerifiedEmail
def send_verification(email: str) -> UnverifiedEmail:
"""Create unverified email with token."""
return UnverifiedEmail(
type="unverified",
email=email,
verification_token=generate_token(),
sent_at=datetime.now()
)
def verify(unverified: UnverifiedEmail, token: str) -> Result[VerifiedEmail, str]:
"""Verify email if token matches."""
if token != unverified.verification_token:
return Error("Invalid verification token")
return Ok(VerifiedEmail(
type="verified",
email=unverified.email,
verified_at=datetime.now()
))
def send_password_reset(email: VerifiedEmail) -> None:
"""Only accepts VerifiedEmail - type system enforces."""
send_email(email.email, "Password reset...")
Pattern: Document approval workflow
interface Draft {
type: "draft";
content: string;
author: string;
lastModified: Date;
}
interface Submitted {
type: "submitted";
content: string;
author: string;
submittedAt: Date;
reviewers: string[];
}
interface Approved {
type: "approved";
content: string;
author: string;
approvedBy: string;
approvedAt: Date;
}
interface Rejected {
type: "rejected";
content: string;
author: string;
rejectedBy: string;
rejectedAt: Date;
reason: string;
}
type DocumentState = Draft | Submitted | Approved | Rejected;
function submit(draft: Draft, reviewers: string[]): Submitted {
return {
type: "submitted",
content: draft.content,
author: draft.author,
submittedAt: new Date(),
reviewers,
};
}
function approve(submitted: Submitted, approver: string): Approved {
if (!submitted.reviewers.includes(approver)) {
throw new Error("Approver must be in reviewers list");
}
return {
type: "approved",
content: submitted.content,
author: submitted.author,
approvedBy: approver,
approvedAt: new Date(),
};
}
See also: domain-modeling.md#pattern-3-state-machines-for-entity-lifecycles for workflows and domain-modeling patterns
Type organization and dependencies
How you organize types affects compilation order and maintainability.
Principle: Dependencies should form a DAG
Types should depend on each other in a directed acyclic graph (no circular dependencies).
Low-level types (primitives, newtypes)
↓
Value objects (email, money, quantity)
↓
Simple domain types (product, customer)
↓
Aggregates (order with order lines)
↓
Workflows (place order, fulfill order)
Pattern: Define types in dependency order
class EmailAddress(BaseModel):
value: str
@field_validator('value')
@classmethod
def must_be_valid(cls, v: str) -> str:
if '@' not in v:
raise ValueError('invalid email')
return v
class ProductCode(BaseModel):
value: str
class Quantity(BaseModel):
value: int
@field_validator('value')
@classmethod
def must_be_positive(cls, v: int) -> int:
if v <= 0:
raise ValueError('quantity must be positive')
return v
class Product(BaseModel):
code: ProductCode
name: str
price: Decimal
class Customer(BaseModel):
email: EmailAddress
name: str
class OrderLine(BaseModel):
product_code: ProductCode
quantity: Quantity
price: Decimal
class Order(BaseModel):
customer_email: EmailAddress
lines: list[OrderLine]
total: Decimal
def place_order(
customer: Customer,
lines: list[OrderLine]
) -> Result[Order, OrderError]:
...
Anti-pattern: Circular dependencies
class Order(BaseModel):
customer: Customer
class Customer(BaseModel):
orders: list[Order]
Fix: Use IDs for references across aggregates
class Order(BaseModel):
customer_id: CustomerId
class Customer(BaseModel):
id: CustomerId
Guideline: Module organization
Organize code to reflect dependency hierarchy:
src/
domain/
primitives.py # EmailAddress, ProductCode, Quantity
value_objects.py # Money, Address
entities.py # Product, Customer (depend on primitives)
aggregates.py # Order (depends on entities)
workflows/
place_order.py # Depends on domain types
fulfill_order.py
This ensures:
- Low-level types defined before high-level types
- No circular imports
- Clear dependency structure
- Easy to test in isolation
See also:
- domain-modeling.md#pattern-1-types-as-domain-vocabulary for domain type patterns
- domain-modeling.md#pattern-5-aggregates-as-consistency-boundaries for aggregate design
Testing ADTs
Property-based testing
Use hypothesis (Python) or similar to test ADT invariants:
from hypothesis import given, strategies as st
from hypothesis.strategies import builds
email_strategy = builds(
EmailAddress,
value=st.emails()
)
@given(email_strategy)
def test_email_roundtrip(email: EmailAddress):
"""Property: parsing serialized email should give original"""
serialized = email.value
parsed = EmailAddress(value=serialized)
assert parsed == email
@given(email_strategy)
def test_email_normalized(email: EmailAddress):
"""Property: emails are always lowercase and trimmed"""
assert email.value == email.value.lower().strip()
order_status_strategy = st.one_of(
builds(Pending, type=st.just("pending")),
builds(Shipped,
type=st.just("shipped"),
tracking_number=st.text(min_size=5, max_size=20)),
)
@given(order_status_strategy)
def test_order_status_serialization(status: OrderStatus):
"""Property: status can roundtrip through JSON"""
json_data = status.json()
parsed = parse_order_status(json_data)
assert parsed == status
Exhaustiveness testing
Ensure pattern matching handles all cases:
def test_all_order_statuses_handled():
"""Ensure processOrder handles all status variants"""
statuses = [
Pending(type="pending"),
Confirmed(type="confirmed", confirmed_at=datetime.now()),
Shipped(type="shipped", tracking_number="ABC123", shipped_at=datetime.now()),
Delivered(type="delivered", delivered_at=datetime.now()),
Cancelled(type="cancelled", reason="test", cancelled_at=datetime.now()),
]
for status in statuses:
order = Order(id=uuid4(), status=status)
result = processOrder(order)
assert result is not None
ADTs in event-sourced systems
In event-sourced architectures, algebraic data types form the foundation of the Decider pattern—the canonical structure for command handling and event application.
Commands are typically sum types (enums) representing possible operations: PlaceOrder | CancelOrder | UpdateQuantity.
Each variant carries the data necessary to execute that specific command.
Events are also sum types representing facts that occurred: OrderPlaced | OrderCancelled | QuantityUpdated.
Event variants include all data necessary to apply the state change, including failure events like PaymentFailed or InsufficientInventory.
State is typically a product type (struct) containing all aggregate fields together: Order { id, customer, items, status, total }.
State fields are often monoidal (can be combined associatively), enabling efficient event replay and incremental updates.
The Decider pattern composes these ADTs into a cohesive structure:
struct Decider<Command, Event, State> {
decide: fn(&Command, &State) -> Vec<Event>,
evolve: fn(&State, &Event) -> State,
initial_state: State,
}
The decide function validates commands against current state and produces events (possibly including rejection events).
The evolve function applies events to state, producing new state.
Together they form an algebra where commands flow through validation to produce facts, and facts accumulate into state.
See event-sourcing.md#the-decider-pattern for operational patterns and preferences-theoretical-foundations references/decide-evolve-lens.md#the-algebra-lives-in-evolve-not-decide for the categorical foundation, where the algebra structure is located in evolve and decide is the readout leg.
Integration with schema versioning
See ~/.claude/skills/preferences-schema-versioning/SKILL.md for:
- How to configure sqlc to generate ADT types
- Cross-database compatibility (PostgreSQL ENUMs → DuckDB CHECK)
- Migration patterns for evolving ADTs
See ~/.claude/skills/preferences-railway-oriented-programming/SKILL.md for:
- How to use ADTs with Result types for error handling
- Composing operations on sum types with bind/apply