An educational pure functional programming library in TypeScript
2
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add educational comments to workflow-engine example

+269 -53
+146 -29
examples/workflow-engine/with-purus.ts
··· 1 1 /** 2 - * Workflow Engine - with purus 2 + * Workflow Engine Example - Type-Safe Business Logic 3 + * =================================================== 3 4 * 4 - * Same order flow, but: 5 - * - Branded types: can't swap OrderId and CustomerId 6 - * - Typestate: shipOrder only accepts PaidOrder 7 - * - match(): forget an error case = compile error 5 + * This example teaches three advanced purus concepts: 6 + * 7 + * 1. BRANDED TYPES - Create distinct types from primitives 8 + * `type OrderId = string` doesn't prevent swapping OrderId and CustomerId. 9 + * `type OrderId = Branded<string, "OrderId">` does - compiler catches mix-ups. 10 + * 11 + * 2. TYPESTATE - Encode state machines in the type system 12 + * DraftOrder, PaidOrder, ShippedOrder are different types. 13 + * shipOrder(draft) won't compile - you must pay first. 14 + * No runtime status checks needed. 15 + * 16 + * 3. EXHAUSTIVE MATCHING - Handle all error cases or don't compile 17 + * Add a new error variant? Every match() call that doesn't handle it 18 + * becomes a compile error. No silent "forgot to handle this" bugs. 19 + * 20 + * Compare with without-purus.ts to see: 21 + * - The swapped-argument bug that compiles fine (line 71 in that file) 22 + * - Runtime status checks that typestate eliminates 23 + * - Error message sniffing vs typed errors 24 + * 25 + * Prerequisites: http-client example (for basic purus patterns) 26 + * Next: task-queue for concurrency and dependency injection 8 27 */ 9 28 10 29 import { ··· 20 39 matchResult, 21 40 } from "../../src/index" 22 41 23 - // ----------------------------------------------------------------------------- 24 - // Branded types - OrderId and CustomerId can't be mixed up 25 - // ----------------------------------------------------------------------------- 42 + // ============================================================================= 43 + // SECTION 1: Branded Types 44 + // ============================================================================= 45 + // 46 + // THE PROBLEM WITH TYPE ALIASES: 47 + // type OrderId = string 48 + // type CustomerId = string 49 + // const process = (oid: OrderId, cid: CustomerId) => ... 50 + // process(customerId, orderId) // <-- Compiles! Both are just strings. 51 + // 52 + // THE SOLUTION - BRANDED TYPES: 53 + // Branded<T, B> adds a phantom "brand" that makes types incompatible. 54 + // OrderId and CustomerId are both strings at runtime, but the compiler 55 + // treats them as distinct types. 56 + // 57 + // SMART CONSTRUCTOR PATTERN: 58 + // The OrderId() function creates branded values. You could add validation 59 + // here (e.g., check format, prefix) and return Option<OrderId> for safety. 60 + // ============================================================================= 26 61 27 62 type OrderId = Branded<string, "OrderId"> 28 63 const OrderId = (s: string): OrderId => brand(s) ··· 33 68 type ProductId = Branded<string, "ProductId"> 34 69 const ProductId = (s: string): ProductId => brand(s) 35 70 36 - // Try passing a CustomerId where OrderId is expected - TypeScript will stop you. 71 + // TRY THIS: Swap the arguments in a function that takes OrderId and CustomerId. 72 + // TypeScript will catch it immediately - no more runtime surprises. 37 73 38 - // ----------------------------------------------------------------------------- 39 - // Typestate - each order state is a different type 40 - // ----------------------------------------------------------------------------- 74 + // ============================================================================= 75 + // SECTION 2: Typestate Pattern 76 + // ============================================================================= 77 + // 78 + // TRADITIONAL STATUS FIELD APPROACH: 79 + // type Order = { status: "draft" | "paid" | "shipped"; ... } 80 + // const ship = (order: Order) => { 81 + // if (order.status !== "paid") throw new Error("...") // Runtime check 82 + // ... 83 + // } 84 + // 85 + // THE PROBLEM: You can call ship(draftOrder) and it compiles. The error 86 + // only happens at runtime. 87 + // 88 + // TYPESTATE APPROACH: 89 + // Each state is a SEPARATE TYPE. DraftOrder, PaidOrder, ShippedOrder. 90 + // - payOrder() accepts DraftOrder, returns PaidOrder 91 + // - shipOrder() accepts PaidOrder, returns ShippedOrder 92 + // - shipOrder(draftOrder) is a COMPILE ERROR - can't even write invalid code 93 + // 94 + // HOW IT WORKS: 95 + // Entity<T, S> uses a phantom type S to track state. 96 + // transition<T, From, To>() creates a function that only accepts Entity<T, From>. 97 + // ============================================================================= 41 98 42 99 type OrderData = { 43 100 readonly id: OrderId ··· 46 103 readonly quantity: number 47 104 } 48 105 106 + // Three DISTINCT types - the state IS the type, not a field 49 107 type DraftOrder = Entity<OrderData, "Draft"> 50 108 type PaidOrder = Entity<OrderData, "Paid"> 51 109 type ShippedOrder = Entity<OrderData, "Shipped"> 52 110 53 - // No status field to check - the type IS the status. 54 - 111 + // Typed errors - each has specific data for debugging 55 112 type OrderError = 56 113 | { readonly _tag: "InvalidTransition"; readonly from: string; readonly to: string } 57 114 | { readonly _tag: "OrderNotFound"; readonly orderId: OrderId } ··· 66 123 ({ _tag: "PaymentDeclined", reason }), 67 124 } 68 125 69 - // ----------------------------------------------------------------------------- 70 - // State transitions - the types enforce valid transitions 71 - // ----------------------------------------------------------------------------- 126 + // ============================================================================= 127 + // SECTION 3: State Transitions 128 + // ============================================================================= 129 + // 130 + // KEY INSIGHT: The function signatures ARE the state machine documentation. 131 + // payOrder: (DraftOrder) => Result<PaidOrder, OrderError> 132 + // shipOrder: (PaidOrder) => ShippedOrder 133 + // 134 + // From these signatures alone, you know: 135 + // - You can only pay a draft order 136 + // - Payment can fail (returns Result) 137 + // - You can only ship a paid order 138 + // - Shipping never fails (returns ShippedOrder directly) 139 + // 140 + // TRY THIS: Call shipOrder with a DraftOrder. TypeScript stops you: 141 + // "Argument of type 'DraftOrder' is not assignable to parameter of type 'PaidOrder'" 142 + // 143 + // No runtime status checks. No exceptions. Just types. 144 + // ============================================================================= 72 145 146 + // SIGNATURE: DraftOrder -> Result<PaidOrder, OrderError> 73 147 // Only accepts DraftOrder. Try passing a ShippedOrder - won't compile. 74 148 const payOrder = (order: DraftOrder): Result<PaidOrder, OrderError> => { 75 149 console.log(`[Payment] Processing payment for order ${order.id}`) ··· 77 151 return ok(toPaid(order)) 78 152 } 79 153 154 + // SIGNATURE: PaidOrder -> ShippedOrder 80 155 // Only accepts PaidOrder. No runtime check needed. 81 156 const shipOrder = (order: PaidOrder): ShippedOrder => { 82 157 console.log(`[Shipping] Shipping order ${order.id}`) ··· 84 159 return toShipped(order) 85 160 } 86 161 87 - // Branded types prevent the bug from the vanilla version 162 + // Branded types in action - compare with without-purus.ts line 71 88 163 const lookupOrder = ( 89 - orderId: OrderId, 90 - customerId: CustomerId 164 + orderId: OrderId, // First parameter is OrderId 165 + customerId: CustomerId // Second parameter is CustomerId 91 166 ): Result<DraftOrder, OrderError> => { 92 167 if (orderId === "order-1" && customerId === "cust-1") { 93 168 const data: OrderData = { ··· 101 176 return err(OrderError.orderNotFound(orderId)) 102 177 } 103 178 104 - // This won't compile - TypeScript catches the swapped arguments: 179 + // ============================================================================= 180 + // THE BUG THAT BRANDED TYPES PREVENT 181 + // ============================================================================= 182 + // 183 + // In without-purus.ts, this compiles fine: 184 + // lookupOrder(customerId, orderId) // Swapped arguments! 185 + // 186 + // With branded types, TypeScript catches it: 187 + // Argument of type 'CustomerId' is not assignable to parameter of type 'OrderId' 188 + // 189 + // Uncomment the code below to see the error: 190 + // ============================================================================= 191 + 105 192 // const buggyLookup = () => { 106 193 // const customerId = CustomerId("cust-1") 107 194 // const orderId = OrderId("order-1") 108 - // return lookupOrder(customerId, orderId) // Error! 195 + // return lookupOrder(customerId, orderId) // Error! Types are swapped. 109 196 // } 110 197 111 - // ----------------------------------------------------------------------------- 112 - // Error handling - exhaustive matching 113 - // ----------------------------------------------------------------------------- 198 + // ============================================================================= 199 + // SECTION 4: Error Handling 200 + // ============================================================================= 201 + // 202 + // WHY TYPED ERRORS MATTER: 203 + // Without types, you end up with error.message.includes("not found") - fragile! 204 + // 205 + // With typed errors: 206 + // - match() forces you to handle every variant 207 + // - Add a new error type? All unhandled match() calls become compile errors 208 + // - Each handler receives correctly narrowed type (e.g., { orderId } for NotFound) 209 + // 210 + // TRY THIS: Comment out one case below. TypeScript will complain. 211 + // ============================================================================= 114 212 115 213 const formatError = (error: OrderError): string => 116 - // Try removing a case - TypeScript will complain 117 214 match(error)({ 118 215 InvalidTransition: ({ from, to }) => 119 216 `Cannot transition order from ${from} to ${to}`, ··· 123 220 `Payment declined: ${reason}`, 124 221 }) 125 222 126 - // ----------------------------------------------------------------------------- 127 - // Demo 128 - // ----------------------------------------------------------------------------- 223 + // ============================================================================= 224 + // SECTION 5: Demo 225 + // ============================================================================= 226 + // 227 + // NOTICE: 228 + // - No runtime status checks in the business logic 229 + // - matchResult handles success and error cases explicitly 230 + // - The type system guides you through valid state transitions 231 + // ============================================================================= 129 232 130 233 const main = () => { 131 234 console.log("=== Workflow Engine (with purus) ===\n") 132 235 236 + // --------------------------------------------------------------------------- 237 + // Test 1: Successful order flow 238 + // Shows: typestate ensures Draft -> Paid -> Shipped sequence 239 + // --------------------------------------------------------------------------- 133 240 console.log("--- Test 1: Successful order flow ---") 134 241 135 242 const orderId = OrderId("order-1") ··· 139 246 140 247 matchResult<DraftOrder, OrderError, void>( 141 248 (draft) => { 249 + // draft is DraftOrder - we can call payOrder 142 250 const payResult = payOrder(draft) 143 251 matchResult<PaidOrder, OrderError, void>( 144 252 (paid) => { 253 + // paid is PaidOrder - we can call shipOrder 145 254 const shipped = shipOrder(paid) 146 255 console.log(`Order ${shipped.id} shipped successfully!\n`) 147 256 }, ··· 151 260 (error) => console.log(`Error: ${formatError(error)}\n`) 152 261 )(result) 153 262 263 + // --------------------------------------------------------------------------- 264 + // Test 2: Type safety demonstration 265 + // Shows: invalid state transitions are compile errors, not runtime errors 266 + // --------------------------------------------------------------------------- 154 267 console.log("--- Test 2: Type safety (compile-time protection) ---") 155 268 console.log("The following would be compile errors in purus:") 156 269 console.log(" - shipOrder(draftOrder) // Error: DraftOrder not assignable to PaidOrder") 157 270 console.log(" - lookupOrder(customerId, orderId) // Error: CustomerId not assignable to OrderId") 158 271 console.log("These bugs are caught at compile time, not runtime!\n") 159 272 273 + // --------------------------------------------------------------------------- 274 + // Test 3: Error handling 275 + // Shows: typed errors flow through Result, match() handles them 276 + // --------------------------------------------------------------------------- 160 277 console.log("--- Test 3: Order not found ---") 161 278 const notFoundResult = lookupOrder(OrderId("invalid"), customerId) 162 279 matchResult<DraftOrder, OrderError, void>(
+123 -24
examples/workflow-engine/without-purus.ts
··· 1 1 /** 2 - * Workflow Engine - vanilla TypeScript 2 + * Workflow Engine - Vanilla TypeScript (Comparison) 3 + * ================================================== 4 + * 5 + * This is the "before" version. Compare with with-purus.ts to see how 6 + * branded types and typestate prevent entire categories of bugs. 7 + * 8 + * PROBLEMS THIS APPROACH HAS: 9 + * 10 + * 1. TYPE ALIASES DON'T PREVENT MIX-UPS 11 + * OrderId, CustomerId, ProductId are all `string`. 12 + * Swap them accidentally? Compiles fine, fails at runtime. 13 + * See line 71 for a live example of this bug. 14 + * 15 + * 2. STATUS FIELD REQUIRES RUNTIME CHECKS 16 + * Every function that cares about state must check `order.status`. 17 + * Forget a check? Runtime error. The compiler can't help. 18 + * 19 + * 3. ERROR HANDLING IS MESSAGE-BASED 20 + * We detect errors by checking if error.message.includes("something"). 21 + * What if someone changes the error message? Silent failure. 22 + * 23 + * THE BIG BUG: Look at buggyLookup() on line 71. 24 + * Arguments are swapped, but TypeScript says nothing. Both are strings. 3 25 * 4 - * A simple order processing flow. Watch for the bug in lookupOrder - 5 - * it compiles fine but fails at runtime. 26 + * Prerequisites: Basic TypeScript understanding 27 + * Next: Compare with with-purus.ts to see the fix 6 28 */ 7 29 8 - // ----------------------------------------------------------------------------- 9 - // IDs - type aliases don't prevent mixups 10 - // ----------------------------------------------------------------------------- 30 + // ============================================================================= 31 + // SECTION 1: Type Aliases (The Problem) 32 + // ============================================================================= 33 + // 34 + // These look like distinct types, but they're all just `string`. 35 + // TypeScript's structural typing means OrderId = CustomerId = ProductId. 36 + // 37 + // The type aliases are DOCUMENTATION ONLY - no compile-time enforcement. 38 + // ============================================================================= 11 39 12 40 type OrderId = string 13 41 type CustomerId = string 14 42 type ProductId = string 15 43 16 - // All three are just strings, so swapping them won't cause a compile error. 44 + // All three are just strings, so swapping them compiles fine. 45 + // This is a major source of runtime bugs in real codebases. 17 46 18 - // ----------------------------------------------------------------------------- 19 - // Order with status field 20 - // ----------------------------------------------------------------------------- 47 + // ============================================================================= 48 + // SECTION 2: Status Field (The Problem) 49 + // ============================================================================= 50 + // 51 + // We encode state as a field value, not in the type system. 52 + // Every function must manually check status before proceeding. 53 + // 54 + // PROBLEMS: 55 + // - Forget a check? Runtime error. 56 + // - Add a new status? Find every place that checks status manually. 57 + // - Tests must cover every invalid state combination. 58 + // ============================================================================= 21 59 22 60 type OrderStatus = "draft" | "paid" | "shipped" 23 61 ··· 26 64 customerId: CustomerId 27 65 productId: ProductId 28 66 quantity: number 29 - status: OrderStatus 67 + status: OrderStatus // <- State is a field, not part of the type 30 68 } 31 69 32 - // ----------------------------------------------------------------------------- 33 - // State transitions with runtime checks 34 - // ----------------------------------------------------------------------------- 70 + // ============================================================================= 71 + // SECTION 3: State Transitions with Runtime Checks 72 + // ============================================================================= 73 + // 74 + // NOTICE: Every function starts with a status check. 75 + // If you forget the check, invalid transitions silently succeed until runtime. 76 + // 77 + // Compare with-purus.ts where: 78 + // - payOrder() ONLY accepts DraftOrder (enforced by type system) 79 + // - shipOrder() ONLY accepts PaidOrder (enforced by type system) 80 + // - No runtime checks needed 81 + // ============================================================================= 35 82 36 83 const payOrder = (order: Order): Order => { 84 + // RUNTIME CHECK: Must be draft to pay 85 + // What if we forget this? Silent corruption of order state. 37 86 if (order.status !== "draft") { 38 87 throw new Error(`Cannot pay order in ${order.status} status`) 39 88 } ··· 42 91 } 43 92 44 93 const shipOrder = (order: Order): Order => { 94 + // RUNTIME CHECK: Must be paid to ship 95 + // Tests must cover "ship draft order" and "ship shipped order" cases 45 96 if (order.status !== "paid") { 46 97 throw new Error(`Cannot ship order in ${order.status} status`) 47 98 } ··· 49 100 return { ...order, status: "shipped" } 50 101 } 51 102 103 + // ============================================================================= 104 + // THE SWAPPED ARGUMENTS BUG 105 + // ============================================================================= 106 + // 107 + // This is the most common bug that branded types prevent. 108 + // Both orderId and customerId are strings, so TypeScript can't tell them apart. 109 + // ============================================================================= 110 + 52 111 // Both arguments are strings - can you spot the bug below? 53 112 const lookupOrder = (orderId: OrderId, customerId: CustomerId): Order | null => { 54 113 if (orderId === "order-1" && customerId === "cust-1") { ··· 63 122 return null 64 123 } 65 124 66 - // This compiles fine, but the arguments are backwards! 125 + // ============================================================================= 126 + // !!! THE BUG - THIS COMPILES FINE !!! 127 + // ============================================================================= 128 + // 129 + // The arguments are BACKWARDS. customerId is passed as orderId and vice versa. 130 + // TypeScript sees string, string and says "looks good to me!" 131 + // 132 + // This returns null at runtime when you expect an order. 133 + // In a real app, this could be: wrong customer charged, order sent to wrong address, etc. 134 + // ============================================================================= 135 + 67 136 const buggyLookup = () => { 68 137 const customerId: CustomerId = "cust-1" 69 138 const orderId: OrderId = "order-1" 70 139 71 - const order = lookupOrder(customerId, orderId) // Oops - swapped! 140 + // ARGUMENTS ARE SWAPPED! But it compiles because both are strings. 141 + const order = lookupOrder(customerId, orderId) // Oops - wrong order! 72 142 return order 73 143 } 74 144 75 - // ----------------------------------------------------------------------------- 76 - // Error handling 77 - // ----------------------------------------------------------------------------- 145 + // ============================================================================= 146 + // SECTION 4: Error Handling (The Problem) 147 + // ============================================================================= 148 + // 149 + // We detect error types by sniffing the error message. 150 + // 151 + // PROBLEMS: 152 + // - Someone changes "Cannot pay" to "Unable to pay"? Our handling breaks silently. 153 + // - No exhaustive checking - add a new error and we might miss handling it. 154 + // - `unknown` type means we lose all error information. 155 + // ============================================================================= 78 156 79 157 const formatError = (error: unknown): string => { 80 158 if (error instanceof Error) { 81 159 const message = error.message 82 160 83 - // String matching is fragile - what if the message changes? 161 + // MESSAGE SNIFFING: Fragile and breaks if messages change 84 162 if (message.includes("Cannot pay")) { 85 163 return "Payment failed: Order is not in draft status" 86 164 } ··· 92 170 return "Unknown error" 93 171 } 94 172 95 - // ----------------------------------------------------------------------------- 96 - // Demo 97 - // ----------------------------------------------------------------------------- 173 + // ============================================================================= 174 + // SECTION 5: Demo 175 + // ============================================================================= 176 + // 177 + // NOTICE: 178 + // - shipOrder(draftOrder) compiles but throws at runtime (Test 2) 179 + // - buggyLookup() returns null unexpectedly (Test 3) - arguments were swapped 180 + // - Error handling relies on message string matching 181 + // ============================================================================= 98 182 99 183 const main = () => { 100 184 console.log("=== Workflow Engine (without purus) ===\n") 101 185 186 + // --------------------------------------------------------------------------- 187 + // Test 1: Successful order flow 188 + // Shows: works when you call functions in the right order 189 + // --------------------------------------------------------------------------- 102 190 console.log("--- Test 1: Successful order flow ---") 103 191 try { 104 192 const order: Order = { ··· 116 204 console.log(formatError(e)) 117 205 } 118 206 207 + // --------------------------------------------------------------------------- 208 + // Test 2: Invalid state transition 209 + // Shows: calling shipOrder on a draft order COMPILES but FAILS at runtime 210 + // --------------------------------------------------------------------------- 119 211 console.log("--- Test 2: Invalid state transition ---") 120 212 try { 121 213 const order: Order = { ··· 126 218 status: "draft", 127 219 } 128 220 129 - // This throws at runtime - would be nice to catch at compile time 221 + // THIS COMPILES! TypeScript sees Order -> Order and says "fine" 222 + // But it throws at runtime because status !== "paid" 130 223 const shipped = shipOrder(order) 131 224 console.log(`Shipped: ${shipped.id}\n`) 132 225 } catch (e: unknown) { 133 226 console.log(`Error: ${formatError(e)}\n`) 134 227 } 135 228 229 + // --------------------------------------------------------------------------- 230 + // Test 3: The swapped arguments bug in action 231 + // Shows: buggyLookup returns null because arguments are backwards 232 + // --------------------------------------------------------------------------- 136 233 console.log("--- Test 3: Swapped arguments bug ---") 137 234 const order = buggyLookup() 138 235 if (order === null) { 236 + // This happens because we passed (customerId, orderId) instead of (orderId, customerId) 237 + // In a real app, this could mean: wrong customer billed, order lost, etc. 139 238 console.log("Order not found (bug: arguments were swapped!)\n") 140 239 } 141 240