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 workflow-engine example with side-by-side comparison

+384
+45
examples/workflow-engine/README.md
··· 1 + # Workflow Engine 2 + 3 + > Passing the wrong ID to a function shouldn't compile. Invalid state transitions shouldn't be possible. 4 + 5 + ## The Problem 6 + 7 + Business logic is full of: 8 + - **IDs that look alike** - OrderId, CustomerId, ProductId are all strings 9 + - **State transitions** - Can't ship an unpaid order, can't refund a draft 10 + - **Error cases** - OutOfStock, PaymentDeclined, InvalidAddress... 11 + 12 + In vanilla TypeScript: 13 + - Swapping `orderId` and `customerId` compiles fine, fails at runtime 14 + - State checks are scattered `if (order.status === 'paid')` everywhere 15 + - `catch (e: unknown)` means you're guessing what errors can happen 16 + 17 + ## Run Both Versions 18 + 19 + ```bash 20 + bun run examples/workflow-engine/without-purus.ts 21 + bun run examples/workflow-engine/with-purus.ts 22 + ``` 23 + 24 + ## Without purus 25 + 26 + See `without-purus.ts` - realistic code showing: 27 + - All IDs are `string` - easy to swap arguments 28 + - Runtime status checks before every operation 29 + - `switch` statements with forgotten cases 30 + - Errors as thrown exceptions with unknown shape 31 + 32 + ## With purus 33 + 34 + See `with-purus.ts` - same functionality with: 35 + - Branded types: `OrderId` and `CustomerId` can't be mixed 36 + - Typestate: `ship(order: PaidOrder)` - can't ship unpaid at compile time 37 + - `match()` forces handling ALL error cases 38 + - Result type makes errors explicit values 39 + 40 + ## Key Takeaways 41 + 42 + - **Branded types** -> Can't pass OrderId where CustomerId expected 43 + - **Typestate** -> Invalid transitions are compile errors, not runtime checks 44 + - **Exhaustive matching** -> Forget an error case = compile error 45 + - **Errors as values** -> No more `catch (e: unknown)` guessing
+183
examples/workflow-engine/with-purus.ts
··· 1 + /** 2 + * Workflow Engine - WITH purus 3 + * 4 + * Same functionality as without-purus.ts, but with: 5 + * - Branded types: OrderId and CustomerId can't be mixed 6 + * - Typestate: ship(order: PaidOrder) - can't ship unpaid at compile time 7 + * - Exhaustive matching: forget an error case = compile error 8 + * - Errors as values: no more catch (e: unknown) guessing 9 + */ 10 + 11 + import { 12 + // Types 13 + type Branded, 14 + type Entity, 15 + type Result, 16 + 17 + // Constructors 18 + brand, 19 + entity, 20 + transition, 21 + ok, 22 + err, 23 + 24 + // Pattern matching 25 + match, 26 + matchResult, 27 + } from "../../src/index" 28 + 29 + // ----------------------------------------------------------------------------- 30 + // SOLUTION: Branded types - OrderId and CustomerId are distinct! 31 + // ----------------------------------------------------------------------------- 32 + 33 + type OrderId = Branded<string, "OrderId"> 34 + const OrderId = (s: string): OrderId => brand(s) 35 + 36 + type CustomerId = Branded<string, "CustomerId"> 37 + const CustomerId = (s: string): CustomerId => brand(s) 38 + 39 + type ProductId = Branded<string, "ProductId"> 40 + const ProductId = (s: string): ProductId => brand(s) 41 + 42 + // ----------------------------------------------------------------------------- 43 + // SOLUTION: Typestate - Order states are separate types! 44 + // ----------------------------------------------------------------------------- 45 + 46 + type OrderData = { 47 + readonly id: OrderId 48 + readonly customerId: CustomerId 49 + readonly productId: ProductId 50 + readonly quantity: number 51 + } 52 + 53 + // SOLUTION: Each state is a distinct type - not a string field! 54 + type DraftOrder = Entity<OrderData, "Draft"> 55 + type PaidOrder = Entity<OrderData, "Paid"> 56 + type ShippedOrder = Entity<OrderData, "Shipped"> 57 + 58 + // SOLUTION: Typed errors - compiler knows all failure modes 59 + type OrderError = 60 + | { readonly _tag: "InvalidTransition"; readonly from: string; readonly to: string } 61 + | { readonly _tag: "OrderNotFound"; readonly orderId: OrderId } 62 + | { readonly _tag: "PaymentDeclined"; readonly reason: string } 63 + 64 + const OrderError = { 65 + invalidTransition: (from: string, to: string): OrderError => 66 + ({ _tag: "InvalidTransition", from, to }), 67 + orderNotFound: (orderId: OrderId): OrderError => 68 + ({ _tag: "OrderNotFound", orderId }), 69 + paymentDeclined: (reason: string): OrderError => 70 + ({ _tag: "PaymentDeclined", reason }), 71 + } 72 + 73 + // ----------------------------------------------------------------------------- 74 + // SOLUTION: State transitions are type-safe functions 75 + // ----------------------------------------------------------------------------- 76 + 77 + // SOLUTION: Only accepts DraftOrder - can't pass PaidOrder or ShippedOrder! 78 + function payOrder(order: DraftOrder): Result<PaidOrder, OrderError> { 79 + console.log(`[Payment] Processing payment for order ${order.id}`) 80 + // Transition from Draft to Paid 81 + const toPaid = transition<OrderData, "Draft", "Paid">() 82 + return ok(toPaid(order)) 83 + } 84 + 85 + // SOLUTION: Only accepts PaidOrder - compile error if you try to ship a draft! 86 + function shipOrder(order: PaidOrder): ShippedOrder { 87 + console.log(`[Shipping] Shipping order ${order.id}`) 88 + // Transition from Paid to Shipped 89 + const toShipped = transition<OrderData, "Paid", "Shipped">() 90 + return toShipped(order) 91 + } 92 + 93 + // SOLUTION: Arguments have distinct types - swapping them is a compile error! 94 + function lookupOrder( 95 + orderId: OrderId, // <-- Branded type 96 + customerId: CustomerId // <-- Different branded type 97 + ): Result<DraftOrder, OrderError> { 98 + if (orderId === "order-1" && customerId === "cust-1") { 99 + const data: OrderData = { 100 + id: orderId, 101 + customerId: customerId, 102 + productId: ProductId("prod-1"), 103 + quantity: 2, 104 + } 105 + return ok(entity<OrderData, "Draft">(data)) 106 + } 107 + return err(OrderError.orderNotFound(orderId)) 108 + } 109 + 110 + // SOLUTION: This would be a compile error! 111 + // function buggyLookup() { 112 + // const customerId = CustomerId("cust-1") 113 + // const orderId = OrderId("order-1") 114 + // // ERROR: Argument of type 'CustomerId' is not assignable to parameter of type 'OrderId' 115 + // return lookupOrder(customerId, orderId) 116 + // } 117 + 118 + // ----------------------------------------------------------------------------- 119 + // SOLUTION: Exhaustive pattern matching - can't forget error cases 120 + // ----------------------------------------------------------------------------- 121 + 122 + function formatError(error: OrderError): string { 123 + // SOLUTION: If you add a new error type and forget to handle it, compiler errors! 124 + return match(error)({ 125 + InvalidTransition: ({ from, to }) => 126 + `Cannot transition order from ${from} to ${to}`, 127 + OrderNotFound: ({ orderId }) => 128 + `Order ${orderId} not found`, 129 + PaymentDeclined: ({ reason }) => 130 + `Payment declined: ${reason}`, 131 + }) 132 + } 133 + 134 + // ----------------------------------------------------------------------------- 135 + // Demo - run it 136 + // ----------------------------------------------------------------------------- 137 + 138 + function main() { 139 + console.log("=== Workflow Engine (with purus) ===\n") 140 + 141 + // Test 1: Successful flow 142 + console.log("--- Test 1: Successful order flow ---") 143 + 144 + const orderId = OrderId("order-1") 145 + const customerId = CustomerId("cust-1") 146 + 147 + const result = lookupOrder(orderId, customerId) 148 + 149 + matchResult<DraftOrder, OrderError, void>( 150 + (draft) => { 151 + const payResult = payOrder(draft) 152 + matchResult<PaidOrder, OrderError, void>( 153 + (paid) => { 154 + // SOLUTION: shipOrder only accepts PaidOrder - type-safe! 155 + const shipped = shipOrder(paid) 156 + console.log(`Order ${shipped.id} shipped successfully!\n`) 157 + }, 158 + (error) => console.log(`Error: ${formatError(error)}\n`) 159 + )(payResult) 160 + }, 161 + (error) => console.log(`Error: ${formatError(error)}\n`) 162 + )(result) 163 + 164 + // Test 2: Type safety demonstration 165 + console.log("--- Test 2: Type safety (compile-time protection) ---") 166 + console.log("The following would be compile errors in purus:") 167 + console.log(" - shipOrder(draftOrder) // Error: DraftOrder not assignable to PaidOrder") 168 + console.log(" - lookupOrder(customerId, orderId) // Error: CustomerId not assignable to OrderId") 169 + console.log("These bugs are caught at compile time, not runtime!\n") 170 + 171 + // Test 3: Order not found 172 + console.log("--- Test 3: Order not found ---") 173 + const notFoundResult = lookupOrder(OrderId("invalid"), customerId) 174 + matchResult<DraftOrder, OrderError, void>( 175 + (draft) => console.log(`Found: ${draft.id}`), 176 + (error) => console.log(`Error: ${formatError(error)}\n`) 177 + )(notFoundResult) 178 + 179 + console.log("=== Done ===") 180 + } 181 + 182 + main() 183 + process.exit(0)
+156
examples/workflow-engine/without-purus.ts
··· 1 + /** 2 + * Workflow Engine - WITHOUT purus 3 + * 4 + * This is realistic vanilla TypeScript code showing how order processing 5 + * is typically implemented. Notice the pain points marked with "PROBLEM:". 6 + */ 7 + 8 + // ----------------------------------------------------------------------------- 9 + // IDs - just type aliases to string 10 + // ----------------------------------------------------------------------------- 11 + 12 + // PROBLEM: These are all just strings at runtime - easy to mix up! 13 + type OrderId = string 14 + type CustomerId = string 15 + type ProductId = string 16 + 17 + // ----------------------------------------------------------------------------- 18 + // Order with status field 19 + // ----------------------------------------------------------------------------- 20 + 21 + type OrderStatus = "draft" | "paid" | "shipped" 22 + 23 + type Order = { 24 + id: OrderId 25 + customerId: CustomerId 26 + productId: ProductId 27 + quantity: number 28 + status: OrderStatus 29 + } 30 + 31 + // ----------------------------------------------------------------------------- 32 + // Error handling via thrown exceptions 33 + // ----------------------------------------------------------------------------- 34 + 35 + function payOrder(order: Order): Order { 36 + // PROBLEM: Runtime check that could be forgotten 37 + if (order.status !== "draft") { 38 + throw new Error(`Cannot pay order in ${order.status} status`) 39 + } 40 + console.log(`[Payment] Processing payment for order ${order.id}`) 41 + return { ...order, status: "paid" } 42 + } 43 + 44 + function shipOrder(order: Order): Order { 45 + // PROBLEM: Another runtime check - easy to forget or get wrong 46 + if (order.status !== "paid") { 47 + throw new Error(`Cannot ship order in ${order.status} status`) 48 + } 49 + console.log(`[Shipping] Shipping order ${order.id}`) 50 + return { ...order, status: "shipped" } 51 + } 52 + 53 + // PROBLEM: Arguments are all strings - swapping them compiles fine! 54 + function lookupOrder(orderId: OrderId, customerId: CustomerId): Order | null { 55 + // Simulate database lookup 56 + if (orderId === "order-1" && customerId === "cust-1") { 57 + return { 58 + id: orderId, 59 + customerId: customerId, 60 + productId: "prod-1", 61 + quantity: 2, 62 + status: "draft", 63 + } 64 + } 65 + return null 66 + } 67 + 68 + // PROBLEM: This has a bug - arguments are swapped! But it compiles fine. 69 + function buggyLookup() { 70 + const customerId: CustomerId = "cust-1" 71 + const orderId: OrderId = "order-1" 72 + 73 + // PROBLEM: We swapped the arguments - this compiles but returns null! 74 + const order = lookupOrder(customerId, orderId) // <-- BUG: swapped! 75 + return order 76 + } 77 + 78 + // ----------------------------------------------------------------------------- 79 + // Error formatting with switch 80 + // ----------------------------------------------------------------------------- 81 + 82 + function formatError(error: unknown): string { 83 + if (error instanceof Error) { 84 + const message = error.message 85 + 86 + // PROBLEM: String matching is fragile and not exhaustive 87 + if (message.includes("Cannot pay")) { 88 + return "Payment failed: Order is not in draft status" 89 + } 90 + if (message.includes("Cannot ship")) { 91 + return "Shipping failed: Order is not paid" 92 + } 93 + // PROBLEM: Easy to forget cases - no compiler warning! 94 + return `Unknown error: ${message}` 95 + } 96 + return "Unknown error" 97 + } 98 + 99 + // ----------------------------------------------------------------------------- 100 + // Demo - run it 101 + // ----------------------------------------------------------------------------- 102 + 103 + function main() { 104 + console.log("=== Workflow Engine (without purus) ===\n") 105 + 106 + // Test 1: Successful flow 107 + console.log("--- Test 1: Successful order flow ---") 108 + try { 109 + const order: Order = { 110 + id: "order-1", 111 + customerId: "cust-1", 112 + productId: "prod-1", 113 + quantity: 2, 114 + status: "draft", 115 + } 116 + 117 + const paid = payOrder(order) 118 + const shipped = shipOrder(paid) 119 + console.log(`Order ${shipped.id} shipped successfully!\n`) 120 + } catch (e: unknown) { 121 + // PROBLEM: e is unknown - we have to guess what it might be 122 + console.log(formatError(e)) 123 + } 124 + 125 + // Test 2: Invalid transition 126 + console.log("--- Test 2: Invalid state transition ---") 127 + try { 128 + const order: Order = { 129 + id: "order-2", 130 + customerId: "cust-1", 131 + productId: "prod-1", 132 + quantity: 1, 133 + status: "draft", 134 + } 135 + 136 + // PROBLEM: Trying to ship a draft order - fails at runtime, not compile time! 137 + const shipped = shipOrder(order) 138 + console.log(`Shipped: ${shipped.id}\n`) 139 + } catch (e: unknown) { 140 + console.log(`Error: ${formatError(e)}\n`) 141 + } 142 + 143 + // Test 3: Swapped arguments bug 144 + console.log("--- Test 3: Swapped arguments bug ---") 145 + const order = buggyLookup() 146 + if (order === null) { 147 + // PROBLEM: This fails at runtime because we swapped orderId/customerId 148 + // TypeScript didn't catch the bug because both are just `string` 149 + console.log("Order not found (bug: arguments were swapped!)\n") 150 + } 151 + 152 + console.log("=== Done ===") 153 + } 154 + 155 + main() 156 + process.exit(0)