···11/**
22- * Workflow Engine - with purus
22+ * Workflow Engine Example - Type-Safe Business Logic
33+ * ===================================================
34 *
44- * Same order flow, but:
55- * - Branded types: can't swap OrderId and CustomerId
66- * - Typestate: shipOrder only accepts PaidOrder
77- * - match(): forget an error case = compile error
55+ * This example teaches three advanced purus concepts:
66+ *
77+ * 1. BRANDED TYPES - Create distinct types from primitives
88+ * `type OrderId = string` doesn't prevent swapping OrderId and CustomerId.
99+ * `type OrderId = Branded<string, "OrderId">` does - compiler catches mix-ups.
1010+ *
1111+ * 2. TYPESTATE - Encode state machines in the type system
1212+ * DraftOrder, PaidOrder, ShippedOrder are different types.
1313+ * shipOrder(draft) won't compile - you must pay first.
1414+ * No runtime status checks needed.
1515+ *
1616+ * 3. EXHAUSTIVE MATCHING - Handle all error cases or don't compile
1717+ * Add a new error variant? Every match() call that doesn't handle it
1818+ * becomes a compile error. No silent "forgot to handle this" bugs.
1919+ *
2020+ * Compare with without-purus.ts to see:
2121+ * - The swapped-argument bug that compiles fine (line 71 in that file)
2222+ * - Runtime status checks that typestate eliminates
2323+ * - Error message sniffing vs typed errors
2424+ *
2525+ * Prerequisites: http-client example (for basic purus patterns)
2626+ * Next: task-queue for concurrency and dependency injection
827 */
9281029import {
···2039 matchResult,
2140} from "../../src/index"
22412323-// -----------------------------------------------------------------------------
2424-// Branded types - OrderId and CustomerId can't be mixed up
2525-// -----------------------------------------------------------------------------
4242+// =============================================================================
4343+// SECTION 1: Branded Types
4444+// =============================================================================
4545+//
4646+// THE PROBLEM WITH TYPE ALIASES:
4747+// type OrderId = string
4848+// type CustomerId = string
4949+// const process = (oid: OrderId, cid: CustomerId) => ...
5050+// process(customerId, orderId) // <-- Compiles! Both are just strings.
5151+//
5252+// THE SOLUTION - BRANDED TYPES:
5353+// Branded<T, B> adds a phantom "brand" that makes types incompatible.
5454+// OrderId and CustomerId are both strings at runtime, but the compiler
5555+// treats them as distinct types.
5656+//
5757+// SMART CONSTRUCTOR PATTERN:
5858+// The OrderId() function creates branded values. You could add validation
5959+// here (e.g., check format, prefix) and return Option<OrderId> for safety.
6060+// =============================================================================
26612762type OrderId = Branded<string, "OrderId">
2863const OrderId = (s: string): OrderId => brand(s)
···3368type ProductId = Branded<string, "ProductId">
3469const ProductId = (s: string): ProductId => brand(s)
35703636-// Try passing a CustomerId where OrderId is expected - TypeScript will stop you.
7171+// TRY THIS: Swap the arguments in a function that takes OrderId and CustomerId.
7272+// TypeScript will catch it immediately - no more runtime surprises.
37733838-// -----------------------------------------------------------------------------
3939-// Typestate - each order state is a different type
4040-// -----------------------------------------------------------------------------
7474+// =============================================================================
7575+// SECTION 2: Typestate Pattern
7676+// =============================================================================
7777+//
7878+// TRADITIONAL STATUS FIELD APPROACH:
7979+// type Order = { status: "draft" | "paid" | "shipped"; ... }
8080+// const ship = (order: Order) => {
8181+// if (order.status !== "paid") throw new Error("...") // Runtime check
8282+// ...
8383+// }
8484+//
8585+// THE PROBLEM: You can call ship(draftOrder) and it compiles. The error
8686+// only happens at runtime.
8787+//
8888+// TYPESTATE APPROACH:
8989+// Each state is a SEPARATE TYPE. DraftOrder, PaidOrder, ShippedOrder.
9090+// - payOrder() accepts DraftOrder, returns PaidOrder
9191+// - shipOrder() accepts PaidOrder, returns ShippedOrder
9292+// - shipOrder(draftOrder) is a COMPILE ERROR - can't even write invalid code
9393+//
9494+// HOW IT WORKS:
9595+// Entity<T, S> uses a phantom type S to track state.
9696+// transition<T, From, To>() creates a function that only accepts Entity<T, From>.
9797+// =============================================================================
41984299type OrderData = {
43100 readonly id: OrderId
···46103 readonly quantity: number
47104}
48105106106+// Three DISTINCT types - the state IS the type, not a field
49107type DraftOrder = Entity<OrderData, "Draft">
50108type PaidOrder = Entity<OrderData, "Paid">
51109type ShippedOrder = Entity<OrderData, "Shipped">
521105353-// No status field to check - the type IS the status.
5454-111111+// Typed errors - each has specific data for debugging
55112type OrderError =
56113 | { readonly _tag: "InvalidTransition"; readonly from: string; readonly to: string }
57114 | { readonly _tag: "OrderNotFound"; readonly orderId: OrderId }
···66123 ({ _tag: "PaymentDeclined", reason }),
67124}
681256969-// -----------------------------------------------------------------------------
7070-// State transitions - the types enforce valid transitions
7171-// -----------------------------------------------------------------------------
126126+// =============================================================================
127127+// SECTION 3: State Transitions
128128+// =============================================================================
129129+//
130130+// KEY INSIGHT: The function signatures ARE the state machine documentation.
131131+// payOrder: (DraftOrder) => Result<PaidOrder, OrderError>
132132+// shipOrder: (PaidOrder) => ShippedOrder
133133+//
134134+// From these signatures alone, you know:
135135+// - You can only pay a draft order
136136+// - Payment can fail (returns Result)
137137+// - You can only ship a paid order
138138+// - Shipping never fails (returns ShippedOrder directly)
139139+//
140140+// TRY THIS: Call shipOrder with a DraftOrder. TypeScript stops you:
141141+// "Argument of type 'DraftOrder' is not assignable to parameter of type 'PaidOrder'"
142142+//
143143+// No runtime status checks. No exceptions. Just types.
144144+// =============================================================================
72145146146+// SIGNATURE: DraftOrder -> Result<PaidOrder, OrderError>
73147// Only accepts DraftOrder. Try passing a ShippedOrder - won't compile.
74148const payOrder = (order: DraftOrder): Result<PaidOrder, OrderError> => {
75149 console.log(`[Payment] Processing payment for order ${order.id}`)
···77151 return ok(toPaid(order))
78152}
79153154154+// SIGNATURE: PaidOrder -> ShippedOrder
80155// Only accepts PaidOrder. No runtime check needed.
81156const shipOrder = (order: PaidOrder): ShippedOrder => {
82157 console.log(`[Shipping] Shipping order ${order.id}`)
···84159 return toShipped(order)
85160}
861618787-// Branded types prevent the bug from the vanilla version
162162+// Branded types in action - compare with without-purus.ts line 71
88163const lookupOrder = (
8989- orderId: OrderId,
9090- customerId: CustomerId
164164+ orderId: OrderId, // First parameter is OrderId
165165+ customerId: CustomerId // Second parameter is CustomerId
91166): Result<DraftOrder, OrderError> => {
92167 if (orderId === "order-1" && customerId === "cust-1") {
93168 const data: OrderData = {
···101176 return err(OrderError.orderNotFound(orderId))
102177}
103178104104-// This won't compile - TypeScript catches the swapped arguments:
179179+// =============================================================================
180180+// THE BUG THAT BRANDED TYPES PREVENT
181181+// =============================================================================
182182+//
183183+// In without-purus.ts, this compiles fine:
184184+// lookupOrder(customerId, orderId) // Swapped arguments!
185185+//
186186+// With branded types, TypeScript catches it:
187187+// Argument of type 'CustomerId' is not assignable to parameter of type 'OrderId'
188188+//
189189+// Uncomment the code below to see the error:
190190+// =============================================================================
191191+105192// const buggyLookup = () => {
106193// const customerId = CustomerId("cust-1")
107194// const orderId = OrderId("order-1")
108108-// return lookupOrder(customerId, orderId) // Error!
195195+// return lookupOrder(customerId, orderId) // Error! Types are swapped.
109196// }
110197111111-// -----------------------------------------------------------------------------
112112-// Error handling - exhaustive matching
113113-// -----------------------------------------------------------------------------
198198+// =============================================================================
199199+// SECTION 4: Error Handling
200200+// =============================================================================
201201+//
202202+// WHY TYPED ERRORS MATTER:
203203+// Without types, you end up with error.message.includes("not found") - fragile!
204204+//
205205+// With typed errors:
206206+// - match() forces you to handle every variant
207207+// - Add a new error type? All unhandled match() calls become compile errors
208208+// - Each handler receives correctly narrowed type (e.g., { orderId } for NotFound)
209209+//
210210+// TRY THIS: Comment out one case below. TypeScript will complain.
211211+// =============================================================================
114212115213const formatError = (error: OrderError): string =>
116116- // Try removing a case - TypeScript will complain
117214 match(error)({
118215 InvalidTransition: ({ from, to }) =>
119216 `Cannot transition order from ${from} to ${to}`,
···123220 `Payment declined: ${reason}`,
124221 })
125222126126-// -----------------------------------------------------------------------------
127127-// Demo
128128-// -----------------------------------------------------------------------------
223223+// =============================================================================
224224+// SECTION 5: Demo
225225+// =============================================================================
226226+//
227227+// NOTICE:
228228+// - No runtime status checks in the business logic
229229+// - matchResult handles success and error cases explicitly
230230+// - The type system guides you through valid state transitions
231231+// =============================================================================
129232130233const main = () => {
131234 console.log("=== Workflow Engine (with purus) ===\n")
132235236236+ // ---------------------------------------------------------------------------
237237+ // Test 1: Successful order flow
238238+ // Shows: typestate ensures Draft -> Paid -> Shipped sequence
239239+ // ---------------------------------------------------------------------------
133240 console.log("--- Test 1: Successful order flow ---")
134241135242 const orderId = OrderId("order-1")
···139246140247 matchResult<DraftOrder, OrderError, void>(
141248 (draft) => {
249249+ // draft is DraftOrder - we can call payOrder
142250 const payResult = payOrder(draft)
143251 matchResult<PaidOrder, OrderError, void>(
144252 (paid) => {
253253+ // paid is PaidOrder - we can call shipOrder
145254 const shipped = shipOrder(paid)
146255 console.log(`Order ${shipped.id} shipped successfully!\n`)
147256 },
···151260 (error) => console.log(`Error: ${formatError(error)}\n`)
152261 )(result)
153262263263+ // ---------------------------------------------------------------------------
264264+ // Test 2: Type safety demonstration
265265+ // Shows: invalid state transitions are compile errors, not runtime errors
266266+ // ---------------------------------------------------------------------------
154267 console.log("--- Test 2: Type safety (compile-time protection) ---")
155268 console.log("The following would be compile errors in purus:")
156269 console.log(" - shipOrder(draftOrder) // Error: DraftOrder not assignable to PaidOrder")
157270 console.log(" - lookupOrder(customerId, orderId) // Error: CustomerId not assignable to OrderId")
158271 console.log("These bugs are caught at compile time, not runtime!\n")
159272273273+ // ---------------------------------------------------------------------------
274274+ // Test 3: Error handling
275275+ // Shows: typed errors flow through Result, match() handles them
276276+ // ---------------------------------------------------------------------------
160277 console.log("--- Test 3: Order not found ---")
161278 const notFoundResult = lookupOrder(OrderId("invalid"), customerId)
162279 matchResult<DraftOrder, OrderError, void>(
+123-24
examples/workflow-engine/without-purus.ts
···11/**
22- * Workflow Engine - vanilla TypeScript
22+ * Workflow Engine - Vanilla TypeScript (Comparison)
33+ * ==================================================
44+ *
55+ * This is the "before" version. Compare with with-purus.ts to see how
66+ * branded types and typestate prevent entire categories of bugs.
77+ *
88+ * PROBLEMS THIS APPROACH HAS:
99+ *
1010+ * 1. TYPE ALIASES DON'T PREVENT MIX-UPS
1111+ * OrderId, CustomerId, ProductId are all `string`.
1212+ * Swap them accidentally? Compiles fine, fails at runtime.
1313+ * See line 71 for a live example of this bug.
1414+ *
1515+ * 2. STATUS FIELD REQUIRES RUNTIME CHECKS
1616+ * Every function that cares about state must check `order.status`.
1717+ * Forget a check? Runtime error. The compiler can't help.
1818+ *
1919+ * 3. ERROR HANDLING IS MESSAGE-BASED
2020+ * We detect errors by checking if error.message.includes("something").
2121+ * What if someone changes the error message? Silent failure.
2222+ *
2323+ * THE BIG BUG: Look at buggyLookup() on line 71.
2424+ * Arguments are swapped, but TypeScript says nothing. Both are strings.
325 *
44- * A simple order processing flow. Watch for the bug in lookupOrder -
55- * it compiles fine but fails at runtime.
2626+ * Prerequisites: Basic TypeScript understanding
2727+ * Next: Compare with with-purus.ts to see the fix
628 */
72988-// -----------------------------------------------------------------------------
99-// IDs - type aliases don't prevent mixups
1010-// -----------------------------------------------------------------------------
3030+// =============================================================================
3131+// SECTION 1: Type Aliases (The Problem)
3232+// =============================================================================
3333+//
3434+// These look like distinct types, but they're all just `string`.
3535+// TypeScript's structural typing means OrderId = CustomerId = ProductId.
3636+//
3737+// The type aliases are DOCUMENTATION ONLY - no compile-time enforcement.
3838+// =============================================================================
11391240type OrderId = string
1341type CustomerId = string
1442type ProductId = string
15431616-// All three are just strings, so swapping them won't cause a compile error.
4444+// All three are just strings, so swapping them compiles fine.
4545+// This is a major source of runtime bugs in real codebases.
17461818-// -----------------------------------------------------------------------------
1919-// Order with status field
2020-// -----------------------------------------------------------------------------
4747+// =============================================================================
4848+// SECTION 2: Status Field (The Problem)
4949+// =============================================================================
5050+//
5151+// We encode state as a field value, not in the type system.
5252+// Every function must manually check status before proceeding.
5353+//
5454+// PROBLEMS:
5555+// - Forget a check? Runtime error.
5656+// - Add a new status? Find every place that checks status manually.
5757+// - Tests must cover every invalid state combination.
5858+// =============================================================================
21592260type OrderStatus = "draft" | "paid" | "shipped"
2361···2664 customerId: CustomerId
2765 productId: ProductId
2866 quantity: number
2929- status: OrderStatus
6767+ status: OrderStatus // <- State is a field, not part of the type
3068}
31693232-// -----------------------------------------------------------------------------
3333-// State transitions with runtime checks
3434-// -----------------------------------------------------------------------------
7070+// =============================================================================
7171+// SECTION 3: State Transitions with Runtime Checks
7272+// =============================================================================
7373+//
7474+// NOTICE: Every function starts with a status check.
7575+// If you forget the check, invalid transitions silently succeed until runtime.
7676+//
7777+// Compare with-purus.ts where:
7878+// - payOrder() ONLY accepts DraftOrder (enforced by type system)
7979+// - shipOrder() ONLY accepts PaidOrder (enforced by type system)
8080+// - No runtime checks needed
8181+// =============================================================================
35823683const payOrder = (order: Order): Order => {
8484+ // RUNTIME CHECK: Must be draft to pay
8585+ // What if we forget this? Silent corruption of order state.
3786 if (order.status !== "draft") {
3887 throw new Error(`Cannot pay order in ${order.status} status`)
3988 }
···4291}
43924493const shipOrder = (order: Order): Order => {
9494+ // RUNTIME CHECK: Must be paid to ship
9595+ // Tests must cover "ship draft order" and "ship shipped order" cases
4596 if (order.status !== "paid") {
4697 throw new Error(`Cannot ship order in ${order.status} status`)
4798 }
···49100 return { ...order, status: "shipped" }
50101}
51102103103+// =============================================================================
104104+// THE SWAPPED ARGUMENTS BUG
105105+// =============================================================================
106106+//
107107+// This is the most common bug that branded types prevent.
108108+// Both orderId and customerId are strings, so TypeScript can't tell them apart.
109109+// =============================================================================
110110+52111// Both arguments are strings - can you spot the bug below?
53112const lookupOrder = (orderId: OrderId, customerId: CustomerId): Order | null => {
54113 if (orderId === "order-1" && customerId === "cust-1") {
···63122 return null
64123}
651246666-// This compiles fine, but the arguments are backwards!
125125+// =============================================================================
126126+// !!! THE BUG - THIS COMPILES FINE !!!
127127+// =============================================================================
128128+//
129129+// The arguments are BACKWARDS. customerId is passed as orderId and vice versa.
130130+// TypeScript sees string, string and says "looks good to me!"
131131+//
132132+// This returns null at runtime when you expect an order.
133133+// In a real app, this could be: wrong customer charged, order sent to wrong address, etc.
134134+// =============================================================================
135135+67136const buggyLookup = () => {
68137 const customerId: CustomerId = "cust-1"
69138 const orderId: OrderId = "order-1"
701397171- const order = lookupOrder(customerId, orderId) // Oops - swapped!
140140+ // ARGUMENTS ARE SWAPPED! But it compiles because both are strings.
141141+ const order = lookupOrder(customerId, orderId) // Oops - wrong order!
72142 return order
73143}
741447575-// -----------------------------------------------------------------------------
7676-// Error handling
7777-// -----------------------------------------------------------------------------
145145+// =============================================================================
146146+// SECTION 4: Error Handling (The Problem)
147147+// =============================================================================
148148+//
149149+// We detect error types by sniffing the error message.
150150+//
151151+// PROBLEMS:
152152+// - Someone changes "Cannot pay" to "Unable to pay"? Our handling breaks silently.
153153+// - No exhaustive checking - add a new error and we might miss handling it.
154154+// - `unknown` type means we lose all error information.
155155+// =============================================================================
7815679157const formatError = (error: unknown): string => {
80158 if (error instanceof Error) {
81159 const message = error.message
821608383- // String matching is fragile - what if the message changes?
161161+ // MESSAGE SNIFFING: Fragile and breaks if messages change
84162 if (message.includes("Cannot pay")) {
85163 return "Payment failed: Order is not in draft status"
86164 }
···92170 return "Unknown error"
93171}
941729595-// -----------------------------------------------------------------------------
9696-// Demo
9797-// -----------------------------------------------------------------------------
173173+// =============================================================================
174174+// SECTION 5: Demo
175175+// =============================================================================
176176+//
177177+// NOTICE:
178178+// - shipOrder(draftOrder) compiles but throws at runtime (Test 2)
179179+// - buggyLookup() returns null unexpectedly (Test 3) - arguments were swapped
180180+// - Error handling relies on message string matching
181181+// =============================================================================
9818299183const main = () => {
100184 console.log("=== Workflow Engine (without purus) ===\n")
101185186186+ // ---------------------------------------------------------------------------
187187+ // Test 1: Successful order flow
188188+ // Shows: works when you call functions in the right order
189189+ // ---------------------------------------------------------------------------
102190 console.log("--- Test 1: Successful order flow ---")
103191 try {
104192 const order: Order = {
···116204 console.log(formatError(e))
117205 }
118206207207+ // ---------------------------------------------------------------------------
208208+ // Test 2: Invalid state transition
209209+ // Shows: calling shipOrder on a draft order COMPILES but FAILS at runtime
210210+ // ---------------------------------------------------------------------------
119211 console.log("--- Test 2: Invalid state transition ---")
120212 try {
121213 const order: Order = {
···126218 status: "draft",
127219 }
128220129129- // This throws at runtime - would be nice to catch at compile time
221221+ // THIS COMPILES! TypeScript sees Order -> Order and says "fine"
222222+ // But it throws at runtime because status !== "paid"
130223 const shipped = shipOrder(order)
131224 console.log(`Shipped: ${shipped.id}\n`)
132225 } catch (e: unknown) {
133226 console.log(`Error: ${formatError(e)}\n`)
134227 }
135228229229+ // ---------------------------------------------------------------------------
230230+ // Test 3: The swapped arguments bug in action
231231+ // Shows: buggyLookup returns null because arguments are backwards
232232+ // ---------------------------------------------------------------------------
136233 console.log("--- Test 3: Swapped arguments bug ---")
137234 const order = buggyLookup()
138235 if (order === null) {
236236+ // This happens because we passed (customerId, orderId) instead of (orderId, customerId)
237237+ // In a real app, this could mean: wrong customer billed, order lost, etc.
139238 console.log("Order not found (bug: arguments were swapped!)\n")
140239 }
141240