TypeDrop

2026-07-31 Challenge

2026-07-31 Hard

Typed Event-Sourced State Machine

You're building the order-lifecycle engine for an e-commerce platform. Orders move through a strict set of states (e.g. `Pending → Confirmed → Shipped → Delivered`, with cancellation possible from some states), and every transition must be recorded as an immutable event in an append-only log. The hardest part is making TypeScript enforce — at the type level — which events are legal from each state, so invalid transitions are caught at compile time, not at runtime.

Goals

  • Define `TransitionMap` as a mapped type over `OrderState` and provide a `TRANSITIONS` constant that `satisfies` it, preserving literal tuple types.
  • Implement `LegalEvents<S>` as a conditional/indexed-access type that resolves to the exact `OrderEvent` sub-union legal from state `S`, making illegal `applyEvent` calls compile-time errors.
  • Implement `createOrder`, `applyEvent`, `isTerminal`, and `getEventsByType` with correct generic constraints, type predicates, and immutability — without `any` or unsafe casts.
  • Implement `rehydrate` to replay an event log, using `TRANSITIONS` for runtime transition validation and throwing descriptive errors for illegal sequences.
challenge.ts
/** All possible order states. */
type OrderState = "Pending" | "Confirmed" | "Shipped" | "Delivered" | "Cancelled" | "Refunded";

type BaseEvent<T extends string, P extends object = Record<string, never>> =
  { type: T; occurredAt: string } & P;

type OrderConfirmed = BaseEvent<"OrderConfirmed", { confirmedBy: string }>;
type OrderShipped   = BaseEvent<"OrderShipped",   { trackingNumber: string; carrier: string }>;
type OrderCancelled = BaseEvent<"OrderCancelled", { reason: string }>;
// ... (OrderPlaced, OrderDelivered, OrderRefunded follow the same pattern)

type OrderEvent = OrderPlaced | OrderConfirmed | OrderShipped
                | OrderDelivered | OrderCancelled | OrderRefunded;

/** Resolves to the union of events legally applicable in state S. */
type LegalEvents<S extends OrderState> = Extract<OrderEvent, { type: typeof TRANSITIONS[S][number] }>;

// LegalEvents<"Pending">   → OrderConfirmed | OrderCancelled
// LegalEvents<"Delivered"> → OrderRefunded
// LegalEvents<"Cancelled"> → never

function applyEvent<S extends OrderState>(
  aggregate: OrderAggregate & { state: S },
  event: LegalEvents<S>       // ← illegal events are compile-time errors
): OrderAggregate { /* ... */ }
Hints (click to reveal)

Hints

  • For `LegalEvents<S>`, index into `typeof TRANSITIONS[S][number]` to get the union of allowed type-string literals, then use `Extract<OrderEvent, { type: ... }>` to narrow to the matching event sub-types.
  • Use `satisfies TransitionMap` (not `: TransitionMap`) on `TRANSITIONS` so TypeScript widens to `TransitionMap` for assignability checks but keeps the literal tuple types — this is what makes `typeof TRANSITIONS[S][number]` work.
  • In `rehydrate`, you can't track the generic `S` across loop iterations; use `TRANSITIONS[aggregate.state].includes(event.type)` as a runtime guard, then use a single type assertion on the `applyEvent` call (with a comment explaining the safety invariant).

Or clone locally

git clone -b challenge/2026-07-31 https://github.com/niltonheck/typedrop.git