···11+# Workflow Engine
22+33+> Passing the wrong ID to a function shouldn't compile. Invalid state transitions shouldn't be possible.
44+55+## The Problem
66+77+Business logic is full of:
88+- **IDs that look alike** - OrderId, CustomerId, ProductId are all strings
99+- **State transitions** - Can't ship an unpaid order, can't refund a draft
1010+- **Error cases** - OutOfStock, PaymentDeclined, InvalidAddress...
1111+1212+In vanilla TypeScript:
1313+- Swapping `orderId` and `customerId` compiles fine, fails at runtime
1414+- State checks are scattered `if (order.status === 'paid')` everywhere
1515+- `catch (e: unknown)` means you're guessing what errors can happen
1616+1717+## Run Both Versions
1818+1919+```bash
2020+bun run examples/workflow-engine/without-purus.ts
2121+bun run examples/workflow-engine/with-purus.ts
2222+```
2323+2424+## Without purus
2525+2626+See `without-purus.ts` - realistic code showing:
2727+- All IDs are `string` - easy to swap arguments
2828+- Runtime status checks before every operation
2929+- `switch` statements with forgotten cases
3030+- Errors as thrown exceptions with unknown shape
3131+3232+## With purus
3333+3434+See `with-purus.ts` - same functionality with:
3535+- Branded types: `OrderId` and `CustomerId` can't be mixed
3636+- Typestate: `ship(order: PaidOrder)` - can't ship unpaid at compile time
3737+- `match()` forces handling ALL error cases
3838+- Result type makes errors explicit values
3939+4040+## Key Takeaways
4141+4242+- **Branded types** -> Can't pass OrderId where CustomerId expected
4343+- **Typestate** -> Invalid transitions are compile errors, not runtime checks
4444+- **Exhaustive matching** -> Forget an error case = compile error
4545+- **Errors as values** -> No more `catch (e: unknown)` guessing
+183
examples/workflow-engine/with-purus.ts
···11+/**
22+ * Workflow Engine - WITH purus
33+ *
44+ * Same functionality as without-purus.ts, but with:
55+ * - Branded types: OrderId and CustomerId can't be mixed
66+ * - Typestate: ship(order: PaidOrder) - can't ship unpaid at compile time
77+ * - Exhaustive matching: forget an error case = compile error
88+ * - Errors as values: no more catch (e: unknown) guessing
99+ */
1010+1111+import {
1212+ // Types
1313+ type Branded,
1414+ type Entity,
1515+ type Result,
1616+1717+ // Constructors
1818+ brand,
1919+ entity,
2020+ transition,
2121+ ok,
2222+ err,
2323+2424+ // Pattern matching
2525+ match,
2626+ matchResult,
2727+} from "../../src/index"
2828+2929+// -----------------------------------------------------------------------------
3030+// SOLUTION: Branded types - OrderId and CustomerId are distinct!
3131+// -----------------------------------------------------------------------------
3232+3333+type OrderId = Branded<string, "OrderId">
3434+const OrderId = (s: string): OrderId => brand(s)
3535+3636+type CustomerId = Branded<string, "CustomerId">
3737+const CustomerId = (s: string): CustomerId => brand(s)
3838+3939+type ProductId = Branded<string, "ProductId">
4040+const ProductId = (s: string): ProductId => brand(s)
4141+4242+// -----------------------------------------------------------------------------
4343+// SOLUTION: Typestate - Order states are separate types!
4444+// -----------------------------------------------------------------------------
4545+4646+type OrderData = {
4747+ readonly id: OrderId
4848+ readonly customerId: CustomerId
4949+ readonly productId: ProductId
5050+ readonly quantity: number
5151+}
5252+5353+// SOLUTION: Each state is a distinct type - not a string field!
5454+type DraftOrder = Entity<OrderData, "Draft">
5555+type PaidOrder = Entity<OrderData, "Paid">
5656+type ShippedOrder = Entity<OrderData, "Shipped">
5757+5858+// SOLUTION: Typed errors - compiler knows all failure modes
5959+type OrderError =
6060+ | { readonly _tag: "InvalidTransition"; readonly from: string; readonly to: string }
6161+ | { readonly _tag: "OrderNotFound"; readonly orderId: OrderId }
6262+ | { readonly _tag: "PaymentDeclined"; readonly reason: string }
6363+6464+const OrderError = {
6565+ invalidTransition: (from: string, to: string): OrderError =>
6666+ ({ _tag: "InvalidTransition", from, to }),
6767+ orderNotFound: (orderId: OrderId): OrderError =>
6868+ ({ _tag: "OrderNotFound", orderId }),
6969+ paymentDeclined: (reason: string): OrderError =>
7070+ ({ _tag: "PaymentDeclined", reason }),
7171+}
7272+7373+// -----------------------------------------------------------------------------
7474+// SOLUTION: State transitions are type-safe functions
7575+// -----------------------------------------------------------------------------
7676+7777+// SOLUTION: Only accepts DraftOrder - can't pass PaidOrder or ShippedOrder!
7878+function payOrder(order: DraftOrder): Result<PaidOrder, OrderError> {
7979+ console.log(`[Payment] Processing payment for order ${order.id}`)
8080+ // Transition from Draft to Paid
8181+ const toPaid = transition<OrderData, "Draft", "Paid">()
8282+ return ok(toPaid(order))
8383+}
8484+8585+// SOLUTION: Only accepts PaidOrder - compile error if you try to ship a draft!
8686+function shipOrder(order: PaidOrder): ShippedOrder {
8787+ console.log(`[Shipping] Shipping order ${order.id}`)
8888+ // Transition from Paid to Shipped
8989+ const toShipped = transition<OrderData, "Paid", "Shipped">()
9090+ return toShipped(order)
9191+}
9292+9393+// SOLUTION: Arguments have distinct types - swapping them is a compile error!
9494+function lookupOrder(
9595+ orderId: OrderId, // <-- Branded type
9696+ customerId: CustomerId // <-- Different branded type
9797+): Result<DraftOrder, OrderError> {
9898+ if (orderId === "order-1" && customerId === "cust-1") {
9999+ const data: OrderData = {
100100+ id: orderId,
101101+ customerId: customerId,
102102+ productId: ProductId("prod-1"),
103103+ quantity: 2,
104104+ }
105105+ return ok(entity<OrderData, "Draft">(data))
106106+ }
107107+ return err(OrderError.orderNotFound(orderId))
108108+}
109109+110110+// SOLUTION: This would be a compile error!
111111+// function buggyLookup() {
112112+// const customerId = CustomerId("cust-1")
113113+// const orderId = OrderId("order-1")
114114+// // ERROR: Argument of type 'CustomerId' is not assignable to parameter of type 'OrderId'
115115+// return lookupOrder(customerId, orderId)
116116+// }
117117+118118+// -----------------------------------------------------------------------------
119119+// SOLUTION: Exhaustive pattern matching - can't forget error cases
120120+// -----------------------------------------------------------------------------
121121+122122+function formatError(error: OrderError): string {
123123+ // SOLUTION: If you add a new error type and forget to handle it, compiler errors!
124124+ return match(error)({
125125+ InvalidTransition: ({ from, to }) =>
126126+ `Cannot transition order from ${from} to ${to}`,
127127+ OrderNotFound: ({ orderId }) =>
128128+ `Order ${orderId} not found`,
129129+ PaymentDeclined: ({ reason }) =>
130130+ `Payment declined: ${reason}`,
131131+ })
132132+}
133133+134134+// -----------------------------------------------------------------------------
135135+// Demo - run it
136136+// -----------------------------------------------------------------------------
137137+138138+function main() {
139139+ console.log("=== Workflow Engine (with purus) ===\n")
140140+141141+ // Test 1: Successful flow
142142+ console.log("--- Test 1: Successful order flow ---")
143143+144144+ const orderId = OrderId("order-1")
145145+ const customerId = CustomerId("cust-1")
146146+147147+ const result = lookupOrder(orderId, customerId)
148148+149149+ matchResult<DraftOrder, OrderError, void>(
150150+ (draft) => {
151151+ const payResult = payOrder(draft)
152152+ matchResult<PaidOrder, OrderError, void>(
153153+ (paid) => {
154154+ // SOLUTION: shipOrder only accepts PaidOrder - type-safe!
155155+ const shipped = shipOrder(paid)
156156+ console.log(`Order ${shipped.id} shipped successfully!\n`)
157157+ },
158158+ (error) => console.log(`Error: ${formatError(error)}\n`)
159159+ )(payResult)
160160+ },
161161+ (error) => console.log(`Error: ${formatError(error)}\n`)
162162+ )(result)
163163+164164+ // Test 2: Type safety demonstration
165165+ console.log("--- Test 2: Type safety (compile-time protection) ---")
166166+ console.log("The following would be compile errors in purus:")
167167+ console.log(" - shipOrder(draftOrder) // Error: DraftOrder not assignable to PaidOrder")
168168+ console.log(" - lookupOrder(customerId, orderId) // Error: CustomerId not assignable to OrderId")
169169+ console.log("These bugs are caught at compile time, not runtime!\n")
170170+171171+ // Test 3: Order not found
172172+ console.log("--- Test 3: Order not found ---")
173173+ const notFoundResult = lookupOrder(OrderId("invalid"), customerId)
174174+ matchResult<DraftOrder, OrderError, void>(
175175+ (draft) => console.log(`Found: ${draft.id}`),
176176+ (error) => console.log(`Error: ${formatError(error)}\n`)
177177+ )(notFoundResult)
178178+179179+ console.log("=== Done ===")
180180+}
181181+182182+main()
183183+process.exit(0)
+156
examples/workflow-engine/without-purus.ts
···11+/**
22+ * Workflow Engine - WITHOUT purus
33+ *
44+ * This is realistic vanilla TypeScript code showing how order processing
55+ * is typically implemented. Notice the pain points marked with "PROBLEM:".
66+ */
77+88+// -----------------------------------------------------------------------------
99+// IDs - just type aliases to string
1010+// -----------------------------------------------------------------------------
1111+1212+// PROBLEM: These are all just strings at runtime - easy to mix up!
1313+type OrderId = string
1414+type CustomerId = string
1515+type ProductId = string
1616+1717+// -----------------------------------------------------------------------------
1818+// Order with status field
1919+// -----------------------------------------------------------------------------
2020+2121+type OrderStatus = "draft" | "paid" | "shipped"
2222+2323+type Order = {
2424+ id: OrderId
2525+ customerId: CustomerId
2626+ productId: ProductId
2727+ quantity: number
2828+ status: OrderStatus
2929+}
3030+3131+// -----------------------------------------------------------------------------
3232+// Error handling via thrown exceptions
3333+// -----------------------------------------------------------------------------
3434+3535+function payOrder(order: Order): Order {
3636+ // PROBLEM: Runtime check that could be forgotten
3737+ if (order.status !== "draft") {
3838+ throw new Error(`Cannot pay order in ${order.status} status`)
3939+ }
4040+ console.log(`[Payment] Processing payment for order ${order.id}`)
4141+ return { ...order, status: "paid" }
4242+}
4343+4444+function shipOrder(order: Order): Order {
4545+ // PROBLEM: Another runtime check - easy to forget or get wrong
4646+ if (order.status !== "paid") {
4747+ throw new Error(`Cannot ship order in ${order.status} status`)
4848+ }
4949+ console.log(`[Shipping] Shipping order ${order.id}`)
5050+ return { ...order, status: "shipped" }
5151+}
5252+5353+// PROBLEM: Arguments are all strings - swapping them compiles fine!
5454+function lookupOrder(orderId: OrderId, customerId: CustomerId): Order | null {
5555+ // Simulate database lookup
5656+ if (orderId === "order-1" && customerId === "cust-1") {
5757+ return {
5858+ id: orderId,
5959+ customerId: customerId,
6060+ productId: "prod-1",
6161+ quantity: 2,
6262+ status: "draft",
6363+ }
6464+ }
6565+ return null
6666+}
6767+6868+// PROBLEM: This has a bug - arguments are swapped! But it compiles fine.
6969+function buggyLookup() {
7070+ const customerId: CustomerId = "cust-1"
7171+ const orderId: OrderId = "order-1"
7272+7373+ // PROBLEM: We swapped the arguments - this compiles but returns null!
7474+ const order = lookupOrder(customerId, orderId) // <-- BUG: swapped!
7575+ return order
7676+}
7777+7878+// -----------------------------------------------------------------------------
7979+// Error formatting with switch
8080+// -----------------------------------------------------------------------------
8181+8282+function formatError(error: unknown): string {
8383+ if (error instanceof Error) {
8484+ const message = error.message
8585+8686+ // PROBLEM: String matching is fragile and not exhaustive
8787+ if (message.includes("Cannot pay")) {
8888+ return "Payment failed: Order is not in draft status"
8989+ }
9090+ if (message.includes("Cannot ship")) {
9191+ return "Shipping failed: Order is not paid"
9292+ }
9393+ // PROBLEM: Easy to forget cases - no compiler warning!
9494+ return `Unknown error: ${message}`
9595+ }
9696+ return "Unknown error"
9797+}
9898+9999+// -----------------------------------------------------------------------------
100100+// Demo - run it
101101+// -----------------------------------------------------------------------------
102102+103103+function main() {
104104+ console.log("=== Workflow Engine (without purus) ===\n")
105105+106106+ // Test 1: Successful flow
107107+ console.log("--- Test 1: Successful order flow ---")
108108+ try {
109109+ const order: Order = {
110110+ id: "order-1",
111111+ customerId: "cust-1",
112112+ productId: "prod-1",
113113+ quantity: 2,
114114+ status: "draft",
115115+ }
116116+117117+ const paid = payOrder(order)
118118+ const shipped = shipOrder(paid)
119119+ console.log(`Order ${shipped.id} shipped successfully!\n`)
120120+ } catch (e: unknown) {
121121+ // PROBLEM: e is unknown - we have to guess what it might be
122122+ console.log(formatError(e))
123123+ }
124124+125125+ // Test 2: Invalid transition
126126+ console.log("--- Test 2: Invalid state transition ---")
127127+ try {
128128+ const order: Order = {
129129+ id: "order-2",
130130+ customerId: "cust-1",
131131+ productId: "prod-1",
132132+ quantity: 1,
133133+ status: "draft",
134134+ }
135135+136136+ // PROBLEM: Trying to ship a draft order - fails at runtime, not compile time!
137137+ const shipped = shipOrder(order)
138138+ console.log(`Shipped: ${shipped.id}\n`)
139139+ } catch (e: unknown) {
140140+ console.log(`Error: ${formatError(e)}\n`)
141141+ }
142142+143143+ // Test 3: Swapped arguments bug
144144+ console.log("--- Test 3: Swapped arguments bug ---")
145145+ const order = buggyLookup()
146146+ if (order === null) {
147147+ // PROBLEM: This fails at runtime because we swapped orderId/customerId
148148+ // TypeScript didn't catch the bug because both are just `string`
149149+ console.log("Order not found (bug: arguments were swapped!)\n")
150150+ }
151151+152152+ console.log("=== Done ===")
153153+}
154154+155155+main()
156156+process.exit(0)