@jagreehal/workflow 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,920 @@
1
+ # Coming from neverthrow
2
+
3
+ You already get it: **errors should be in the type system, not hidden behind `unknown`**. Both neverthrow and this library share that philosophy.
4
+
5
+ The difference? neverthrow gives you typed Results. This library gives you typed Results *plus* orchestration—retries, timeouts, caching, resume, and visualization built in.
6
+
7
+ **TL;DR:**
8
+ - `andThen` chains → `step()` calls with async/await
9
+ - Same error-first mindset, different syntax
10
+ - Keep your existing neverthrow code—they interop cleanly
11
+
12
+ ## Two philosophies, same goal
13
+
14
+ | | neverthrow | @jagreehal/workflow |
15
+ |---|------------|---------------------|
16
+ | **Mental model** | "The Realist" — explicit about what can fail | "The Orchestrator" — explicit failures + execution control |
17
+ | **Syntax** | Functional chaining (`.andThen()`, `.map()`) | Imperative async/await with `step()` |
18
+ | **Error inference** | Manual union types | Automatic from dependencies |
19
+ | **Orchestration** | DIY (retries, caching, timeouts) | Built-in primitives |
20
+
21
+ Both make your functions *honest*—the signature says what can go wrong. The difference is in ergonomics and what's included.
22
+
23
+ ---
24
+
25
+ ## Quick orientation
26
+
27
+ Before diving into examples, here are the key concepts:
28
+
29
+ ### Two runners: `createWorkflow` vs `run`
30
+
31
+ | Runner | When to use | Features |
32
+ |--------|-------------|----------|
33
+ | `createWorkflow({ deps })` | Multi-step workflows needing orchestration | Auto error inference, caching, resume, events |
34
+ | `run(async (step) => ...)` | Simple one-off sequences without deps injection | Minimal, no caching/resume |
35
+
36
+ Most examples in this guide use `createWorkflow` because it's the common case. Use `run()` for quick operations where you don't need dependency injection or orchestration features.
37
+
38
+ ### Two forms of `step()`
39
+
40
+ ```typescript
41
+ // Form 1: Direct result - use for simple steps
42
+ const user = await step(deps.fetchUser(id));
43
+
44
+ // Form 2: Lazy function with options - use when you need caching/resume
45
+ const user = await step(() => deps.fetchUser(id), { key: 'user:' + id });
46
+ ```
47
+
48
+ **Rule of thumb:**
49
+ - Use `step(result)` for normal steps
50
+ - Use `step(() => fn(), { key })` when you need **caching**, **resume**, or **retry/timeout**
51
+
52
+ The key enables these features. Without a key, the step runs but isn't cached or resumable.
53
+
54
+ ### Type literal tip
55
+
56
+ Use `as const` to keep error unions narrow:
57
+
58
+ ```typescript
59
+ return err('NOT_FOUND' as const); // error is literal 'NOT_FOUND'
60
+ return err('NOT_FOUND'); // error widens to string
61
+ ```
62
+
63
+ This matters because narrow unions enable exhaustive `switch` handling and better autocomplete.
64
+
65
+ ### About `UnexpectedError`
66
+
67
+ By default, workflow results include `UnexpectedError` alongside your typed errors:
68
+
69
+ ```typescript
70
+ // result.error: 'NOT_FOUND' | 'FETCH_ERROR' | UnexpectedError
71
+ ```
72
+
73
+ `UnexpectedError` wraps uncaught exceptions so they don't crash your app—it contains the thrown value in `cause` for debugging. If you want a **closed** error union (no `UnexpectedError`), use strict mode:
74
+
75
+ ```typescript
76
+ const workflow = createWorkflow(
77
+ { fetchUser },
78
+ {
79
+ strict: true,
80
+ catchUnexpected: (thrown) => 'UNEXPECTED' as const,
81
+ }
82
+ );
83
+ // result.error: 'NOT_FOUND' | 'UNEXPECTED' (exactly)
84
+ ```
85
+
86
+ ---
87
+
88
+ ## The problem both libraries solve
89
+
90
+ Standard async/await hides failure information from the type system:
91
+
92
+ ```typescript
93
+ async function loadDashboard(userId: string) {
94
+ try {
95
+ const user = await fetchUser(userId);
96
+ const org = await fetchOrg(user.orgId);
97
+ return { user, org };
98
+ } catch (e) {
99
+ throw new Error('Failed to load dashboard');
100
+ }
101
+ }
102
+ ```
103
+
104
+ TypeScript sees this as returning `{ user, org }` or throwing `unknown`. All the real errors—NOT_FOUND, PERMISSION_DENIED, TIMEOUT—are erased.
105
+
106
+ Both neverthrow and workflow fix this by making errors part of the return type.
107
+
108
+ ---
109
+
110
+ ## Pattern-by-pattern comparison
111
+
112
+ ### Basic Result construction
113
+
114
+ **neverthrow:**
115
+
116
+ ```typescript
117
+ import { ok, err, Result } from 'neverthrow';
118
+
119
+ const success = ok({ id: '1', name: 'Alice' });
120
+ const failure = err('NOT_FOUND' as const);
121
+
122
+ // Access with methods
123
+ success.isOk() // true
124
+ success._unsafeUnwrap() // { id: '1', name: 'Alice' }
125
+ failure.isErr() // true
126
+ failure._unsafeUnwrapErr() // 'NOT_FOUND'
127
+ ```
128
+
129
+ **workflow:**
130
+
131
+ ```typescript
132
+ import { ok, err } from '@jagreehal/workflow';
133
+
134
+ const success = ok({ id: '1', name: 'Alice' });
135
+ const failure = err('NOT_FOUND' as const);
136
+
137
+ // Access with properties
138
+ success.ok // true
139
+ success.value // { id: '1', name: 'Alice' }
140
+ failure.ok // false
141
+ failure.error // 'NOT_FOUND'
142
+ ```
143
+
144
+ ---
145
+
146
+ ### Sequential operations
147
+
148
+ **neverthrow** uses `andThen` to chain operations:
149
+
150
+ ```typescript
151
+ fetchUser(userId)
152
+ .andThen(user =>
153
+ fetchOrg(user.orgId).andThen(org =>
154
+ fetchStats(org.id).map(stats => ({ user, org, stats }))
155
+ )
156
+ );
157
+ ```
158
+
159
+ With 3+ operations, the nesting becomes unwieldy.
160
+
161
+ **workflow** uses `step()` with standard async/await:
162
+
163
+ ```typescript
164
+ import { createWorkflow } from '@jagreehal/workflow';
165
+
166
+ const loadDashboard = createWorkflow({ fetchUser, fetchOrg, fetchStats });
167
+
168
+ const result = await loadDashboard(async (step, deps) => {
169
+ const user = await step(deps.fetchUser(userId));
170
+ const org = await step(deps.fetchOrg(user.orgId));
171
+ const stats = await step(deps.fetchStats(org.id));
172
+ return { user, org, stats };
173
+ });
174
+ ```
175
+
176
+ The `step()` function unwraps `Ok` values and short-circuits on `Err`—same semantics as `andThen`, but stays flat regardless of depth.
177
+
178
+ ---
179
+
180
+ ### Early exit
181
+
182
+ **neverthrow:**
183
+
184
+ ```typescript
185
+ fetchUser(id)
186
+ .andThen(user => assertActive(user))
187
+ .andThen(user => fetchPermissions(user.id));
188
+ ```
189
+
190
+ **workflow:**
191
+
192
+ ```typescript
193
+ const result = await run(async (step) => {
194
+ const user = await step(fetchUser(id));
195
+ await step(assertActive(user)); // stops here if user inactive
196
+ const permissions = await step(fetchPermissions(user.id));
197
+ return { user, permissions };
198
+ });
199
+ ```
200
+
201
+ If any step returns `Err`, execution stops immediately—no manual `if (result.isErr())` checks needed.
202
+
203
+ ---
204
+
205
+ ### Transforming values (map)
206
+
207
+ **neverthrow:**
208
+
209
+ ```typescript
210
+ fetchUser(id).map(user => user.name);
211
+ ```
212
+
213
+ **workflow:**
214
+
215
+ ```typescript
216
+ const result = await run(async (step) => {
217
+ const user = await step(fetchUser(id));
218
+ return user.name; // just use the value directly
219
+ });
220
+ ```
221
+
222
+ Since `step()` unwraps the value, you work with it naturally.
223
+
224
+ ---
225
+
226
+ ### Error recovery (orElse)
227
+
228
+ **neverthrow:**
229
+
230
+ ```typescript
231
+ fetchUser(id).orElse(error => {
232
+ if (error === 'NOT_FOUND') return ok(defaultUser);
233
+ return err(error);
234
+ });
235
+ ```
236
+
237
+ **workflow** — now with direct `orElse()` function:
238
+
239
+ ```typescript
240
+ import { orElse, ok, err } from '@jagreehal/workflow';
241
+
242
+ // Direct equivalent to neverthrow's orElse
243
+ const userResult = orElse(
244
+ await fetchUser(id),
245
+ error => error === 'NOT_FOUND' ? ok(defaultUser) : err(error)
246
+ );
247
+
248
+ // Or use recover() when recovery cannot fail
249
+ import { recover } from '@jagreehal/workflow';
250
+
251
+ const user = recover(
252
+ await fetchUser(id),
253
+ error => error === 'NOT_FOUND' ? defaultUser : guestUser
254
+ );
255
+ // user is always ok() - recovery guarantees success
256
+ ```
257
+
258
+ For pattern matching, use `match()`:
259
+
260
+ ```typescript
261
+ import { match } from '@jagreehal/workflow';
262
+
263
+ const user = match(await fetchUser(id), {
264
+ ok: (value) => value,
265
+ err: (error) => error === 'NOT_FOUND' ? defaultUser : guestUser,
266
+ });
267
+ ```
268
+
269
+ ---
270
+
271
+ ### Wrapping throwing code (fromPromise)
272
+
273
+ **neverthrow:**
274
+
275
+ ```typescript
276
+ import { ResultAsync } from 'neverthrow';
277
+
278
+ const result = ResultAsync.fromPromise(
279
+ fetch('/api/data').then(r => r.json()),
280
+ () => 'FETCH_FAILED' as const
281
+ );
282
+ ```
283
+
284
+ **@jagreehal/workflow** has direct equivalents:
285
+
286
+ ```typescript
287
+ import { fromPromise, tryAsync } from '@jagreehal/workflow';
288
+
289
+ // fromPromise - wrap an existing Promise
290
+ const result = await fromPromise(
291
+ fetch('/api/data').then(r => r.json()),
292
+ () => 'FETCH_FAILED' as const
293
+ );
294
+
295
+ // tryAsync - wrap an async function (often cleaner)
296
+ const result = await tryAsync(
297
+ async () => {
298
+ const res = await fetch('/api/data');
299
+ return res.json();
300
+ },
301
+ () => 'FETCH_FAILED' as const
302
+ );
303
+ ```
304
+
305
+ Both `fromPromise` and `tryAsync` support typed error mapping. Use `tryAsync` when the async logic is more than a one-liner.
306
+
307
+ ---
308
+
309
+ ### Parallel execution (combine)
310
+
311
+ **neverthrow:**
312
+
313
+ ```typescript
314
+ import { ResultAsync } from 'neverthrow';
315
+
316
+ const result = await ResultAsync.combine([
317
+ fetchUser(id),
318
+ fetchPermissions(id),
319
+ ]);
320
+ ```
321
+
322
+ **workflow:**
323
+
324
+ ```typescript
325
+ import { allAsync } from '@jagreehal/workflow';
326
+
327
+ const result = await allAsync([
328
+ fetchUser(id),
329
+ fetchPermissions(id),
330
+ ]);
331
+
332
+ if (result.ok) {
333
+ const [user, permissions] = result.value;
334
+ }
335
+ ```
336
+
337
+ Both fail fast on the first error.
338
+
339
+ ---
340
+
341
+ ### Collecting all errors (combineWithAllErrors)
342
+
343
+ **neverthrow:**
344
+
345
+ ```typescript
346
+ Result.combineWithAllErrors([
347
+ validateEmail(email),
348
+ validatePassword(password),
349
+ ]);
350
+ ```
351
+
352
+ **workflow:**
353
+
354
+ ```typescript
355
+ import { allSettled } from '@jagreehal/workflow';
356
+
357
+ const result = allSettled([
358
+ validateEmail(email),
359
+ validatePassword(password),
360
+ ]);
361
+ // If any fail: { ok: false, error: [{ error: 'INVALID_EMAIL' }, { error: 'WEAK_PASSWORD' }] }
362
+ ```
363
+
364
+ Useful for form validation where you want all errors at once.
365
+
366
+ ---
367
+
368
+ ### Pattern matching (match)
369
+
370
+ **neverthrow:**
371
+
372
+ ```typescript
373
+ result.match(
374
+ (value) => console.log('Success:', value),
375
+ (error) => console.log('Error:', error)
376
+ );
377
+ ```
378
+
379
+ **workflow:**
380
+
381
+ ```typescript
382
+ import { match } from '@jagreehal/workflow';
383
+
384
+ match(result, {
385
+ ok: (value) => console.log('Success:', value),
386
+ err: (error) => console.log('Error:', error),
387
+ });
388
+ ```
389
+
390
+ Or use simple conditionals:
391
+
392
+ ```typescript
393
+ if (result.ok) {
394
+ console.log('Success:', result.value);
395
+ } else {
396
+ console.log('Error:', result.error);
397
+ }
398
+ ```
399
+
400
+ ---
401
+
402
+ ### Transformations (map, mapError)
403
+
404
+ **neverthrow:**
405
+
406
+ ```typescript
407
+ fetchUser(id)
408
+ .map(user => user.name)
409
+ .mapErr(error => ({ code: error, message: 'User not found' }));
410
+ ```
411
+
412
+ **workflow:**
413
+
414
+ ```typescript
415
+ import { map, mapError } from '@jagreehal/workflow';
416
+
417
+ const userResult = await fetchUser(id);
418
+ const nameResult = map(userResult, user => user.name);
419
+ const enrichedResult = mapError(userResult, error => ({
420
+ code: error,
421
+ message: 'User not found',
422
+ }));
423
+ ```
424
+
425
+ ---
426
+
427
+ ### Automatic error type inference
428
+
429
+ **neverthrow** requires you to declare error unions explicitly:
430
+
431
+ ```typescript
432
+ // You MUST manually track the error union
433
+ type SignUpError = 'INVALID_EMAIL' | 'WEAK_PASSWORD' | 'DB_ERROR';
434
+
435
+ const signUp = (
436
+ email: string,
437
+ password: string
438
+ ): ResultAsync<User, SignUpError> =>
439
+ validateEmail(email)
440
+ .andThen(() => validatePassword(password))
441
+ .andThen(() => createUser(email, password));
442
+ ```
443
+
444
+ **workflow** with `createWorkflow` infers them automatically:
445
+
446
+ ```typescript
447
+ import { createWorkflow } from '@jagreehal/workflow';
448
+
449
+ // NO manual type annotation needed!
450
+ const signUp = createWorkflow({
451
+ validateEmail, // returns AsyncResult<string, 'INVALID_EMAIL'>
452
+ validatePassword, // returns AsyncResult<string, 'WEAK_PASSWORD'>
453
+ createUser, // returns AsyncResult<User, 'DB_ERROR'>
454
+ });
455
+
456
+ const result = await signUp(async (step, deps) => {
457
+ const email = await step(deps.validateEmail('alice@example.com'));
458
+ const password = await step(deps.validatePassword('securepass123'));
459
+ return await step(deps.createUser(email, password));
460
+ });
461
+
462
+ // TypeScript knows: Result<User, 'INVALID_EMAIL' | 'WEAK_PASSWORD' | 'DB_ERROR' | UnexpectedError>
463
+ ```
464
+
465
+ The error union stays in sync as you add or remove steps.
466
+
467
+ ---
468
+
469
+ ## Why async/await wins for complex logic
470
+
471
+ Beyond syntax preference, there are two structural advantages to workflow's imperative approach that become significant as your code grows:
472
+
473
+ ### Variable scoping (no closure drilling)
474
+
475
+ **neverthrow** — accessing variables from earlier steps means nesting or explicit passing:
476
+
477
+ ```typescript
478
+ fetchUser(id)
479
+ .andThen(user =>
480
+ fetchPosts(user.id).andThen(posts =>
481
+ fetchComments(posts[0].id).andThen(comments =>
482
+ // To use 'user' here, we had to pass through every layer
483
+ calculateAnalytics(user, posts, comments)
484
+ )
485
+ )
486
+ );
487
+ ```
488
+
489
+ **workflow** — all variables are in block scope:
490
+
491
+ ```typescript
492
+ const result = await workflow(async (step) => {
493
+ const user = await step(fetchUser(id));
494
+ const posts = await step(fetchPosts(user.id));
495
+ const comments = await step(fetchComments(posts[0].id));
496
+
497
+ // All variables accessible—no drilling needed
498
+ return calculateAnalytics(user, posts, comments);
499
+ });
500
+ ```
501
+
502
+ This matters most in checkout flows, data pipelines, and any multi-step process where later steps reference earlier results.
503
+
504
+ ### Native control flow (branching without gymnastics)
505
+
506
+ **neverthrow** — conditional logic requires functional patterns:
507
+
508
+ ```typescript
509
+ fetchTenant(id).andThen(tenant => {
510
+ if (tenant.plan === 'free') {
511
+ return calculateFreeUsage(); // Must return compatible Result type
512
+ }
513
+ return fetchUsers()
514
+ .andThen(users => fetchResources()
515
+ .andThen(resources => calculateUsage(tenant, users, resources)));
516
+ });
517
+ ```
518
+
519
+ All branches must return the same Result type, and you lose access to `tenant` inside deeper callbacks without passing it.
520
+
521
+ **workflow** — just JavaScript:
522
+
523
+ ```typescript
524
+ const result = await workflow(async (step) => {
525
+ const tenant = await step(fetchTenant(id));
526
+
527
+ if (tenant.plan === 'free') {
528
+ return await step(calculateFreeUsage(tenant));
529
+ }
530
+
531
+ const [users, resources] = await step(allAsync([
532
+ fetchUsers(),
533
+ fetchResources()
534
+ ]));
535
+
536
+ switch (tenant.plan) {
537
+ case 'pro':
538
+ await step(sendProNotification(tenant));
539
+ break;
540
+ case 'enterprise':
541
+ await step(sendEnterpriseNotification(tenant));
542
+ break;
543
+ }
544
+
545
+ return await step(calculateUsage(tenant, users, resources));
546
+ });
547
+ ```
548
+
549
+ Standard `if`, `switch`, `for`, `while`—no learning curve for conditional logic.
550
+
551
+ ---
552
+
553
+ ## What you get on top of neverthrow
554
+
555
+ Everything above is pattern translation—same capabilities, different syntax. These features are *new*—they don't have neverthrow equivalents because they're about orchestration, not just error handling.
556
+
557
+ ### Retry with backoff
558
+
559
+ Automatically retry failed operations:
560
+
561
+ ```typescript
562
+ const workflow = createWorkflow({ flakyOperation });
563
+
564
+ const result = await workflow(async (step, deps) => {
565
+ return await step.retry(
566
+ () => deps.flakyOperation(),
567
+ {
568
+ attempts: 3,
569
+ backoff: 'exponential', // 'fixed' | 'linear' | 'exponential'
570
+ initialDelay: 100,
571
+ maxDelay: 5000,
572
+ jitter: true,
573
+ retryOn: (error) => error !== 'FATAL',
574
+ }
575
+ );
576
+ });
577
+ ```
578
+
579
+ ---
580
+
581
+ ### Timeout protection
582
+
583
+ Prevent operations from hanging:
584
+
585
+ ```typescript
586
+ const data = await step.withTimeout(
587
+ () => slowOperation(),
588
+ { ms: 5000 }
589
+ );
590
+ ```
591
+
592
+ With AbortSignal for cancellable operations:
593
+
594
+ ```typescript
595
+ const data = await step.withTimeout(
596
+ (signal) => fetch('/api/data', { signal }),
597
+ { ms: 5000, signal: true }
598
+ );
599
+ ```
600
+
601
+ ---
602
+
603
+ ### Step caching
604
+
605
+ Cache expensive operations by key:
606
+
607
+ ```typescript
608
+ const workflow = createWorkflow({ fetchUser }, { cache: new Map() });
609
+
610
+ const result = await workflow(async (step, deps) => {
611
+ const user = await step(() => deps.fetchUser('1'), { key: 'user:1' });
612
+
613
+ // Same key = cache hit, fetchUser not called again
614
+ const userAgain = await step(() => deps.fetchUser('1'), { key: 'user:1' });
615
+
616
+ return user;
617
+ });
618
+ ```
619
+
620
+ ---
621
+
622
+ ### Save and resume execution
623
+
624
+ Persist completed steps and resume without re-running them:
625
+
626
+ ```typescript
627
+ import { createWorkflow, isStepComplete, type ResumeStateEntry } from '@jagreehal/workflow';
628
+
629
+ const savedSteps = new Map<string, ResumeStateEntry>();
630
+
631
+ const workflow = createWorkflow({ fetchUser, processPayment }, {
632
+ onEvent: (event) => {
633
+ if (isStepComplete(event)) {
634
+ savedSteps.set(event.stepKey, { result: event.result, meta: event.meta });
635
+ }
636
+ }
637
+ });
638
+
639
+ // First run - payment succeeds, then process crashes
640
+ await workflow(async (step, deps) => {
641
+ const user = await step(() => deps.fetchUser(id), { key: 'user' });
642
+ const payment = await step(() => deps.processPayment(user), { key: 'payment' });
643
+ await step(() => sendConfirmation(payment), { key: 'confirm' });
644
+ });
645
+
646
+ // Resume later - user and payment steps are skipped (already completed)
647
+ const workflow2 = createWorkflow({ fetchUser, processPayment }, {
648
+ resumeState: { steps: savedSteps }
649
+ });
650
+ ```
651
+
652
+ ---
653
+
654
+ ### Event stream for observability
655
+
656
+ Every workflow emits structured events:
657
+
658
+ ```typescript
659
+ const workflow = createWorkflow({ fetchUser }, {
660
+ onEvent: (event) => {
661
+ // workflow_start | workflow_success | workflow_error
662
+ // step_start | step_success | step_error | step_complete
663
+ // step_retry | step_timeout | step_retries_exhausted
664
+ console.log(event.type, event.stepKey, event.durationMs);
665
+ }
666
+ });
667
+ ```
668
+
669
+ Note: `step_complete` is only emitted for steps with a `key` (enables caching/resume).
670
+
671
+ ---
672
+
673
+ ### Saga/compensation patterns
674
+
675
+ Define rollback actions for distributed transactions:
676
+
677
+ ```typescript
678
+ import { createSagaWorkflow } from '@jagreehal/workflow';
679
+
680
+ const checkoutSaga = createSagaWorkflow(
681
+ { reserveInventory, chargeCard, sendConfirmation }
682
+ );
683
+
684
+ const result = await checkoutSaga(async (saga, deps) => {
685
+ const reservation = await saga.step(
686
+ () => deps.reserveInventory(items),
687
+ {
688
+ name: 'reserve-inventory',
689
+ compensate: (res) => releaseInventory(res.reservationId),
690
+ }
691
+ );
692
+
693
+ const payment = await saga.step(
694
+ () => deps.chargeCard(amount),
695
+ {
696
+ name: 'charge-card',
697
+ compensate: (p) => refundPayment(p.transactionId),
698
+ }
699
+ );
700
+
701
+ // If sendConfirmation fails, compensations run in reverse:
702
+ // 1. refundPayment(payment.transactionId)
703
+ // 2. releaseInventory(reservation.reservationId)
704
+ await saga.step(
705
+ () => deps.sendConfirmation(email),
706
+ { name: 'send-confirmation' }
707
+ );
708
+
709
+ return { reservation, payment };
710
+ });
711
+ ```
712
+
713
+ ---
714
+
715
+ ### Circuit breaker
716
+
717
+ Prevent cascading failures:
718
+
719
+ ```typescript
720
+ import { createCircuitBreaker, isCircuitOpenError } from '@jagreehal/workflow';
721
+
722
+ const breaker = createCircuitBreaker('external-api', {
723
+ failureThreshold: 5,
724
+ resetTimeout: 30000,
725
+ halfOpenMax: 3,
726
+ });
727
+
728
+ const result = await breaker.executeResult(async () => {
729
+ return ok(await fetchFromExternalApi());
730
+ });
731
+
732
+ if (!result.ok && isCircuitOpenError(result.error)) {
733
+ console.log(`Circuit open, retry after ${result.error.retryAfterMs}ms`);
734
+ }
735
+ ```
736
+
737
+ ---
738
+
739
+ ### Rate limiting
740
+
741
+ Control throughput:
742
+
743
+ ```typescript
744
+ import { createRateLimiter } from '@jagreehal/workflow';
745
+
746
+ const limiter = createRateLimiter('api-calls', {
747
+ maxPerSecond: 10,
748
+ burstCapacity: 20,
749
+ strategy: 'wait',
750
+ });
751
+
752
+ const data = await limiter.execute(async () => {
753
+ return await callExternalApi();
754
+ });
755
+ ```
756
+
757
+ ---
758
+
759
+ ### Visualization
760
+
761
+ Render workflow execution:
762
+
763
+ ```typescript
764
+ import { createIRBuilder, renderToAscii, renderToMermaid } from '@jagreehal/workflow/visualize';
765
+
766
+ const builder = createIRBuilder();
767
+ const workflow = createWorkflow({ fetchUser, fetchPosts }, {
768
+ onEvent: (event) => builder.addEvent(event),
769
+ });
770
+
771
+ await workflow(async (step, deps) => {
772
+ const user = await step(() => deps.fetchUser('1'), { name: 'Fetch user', key: 'user' });
773
+ const posts = await step(() => deps.fetchPosts(user.id), { name: 'Fetch posts', key: 'posts' });
774
+ return { user, posts };
775
+ });
776
+
777
+ console.log(renderToAscii(builder.getIR()));
778
+ console.log(renderToMermaid(builder.getIR()));
779
+ ```
780
+
781
+ ---
782
+
783
+ ### Strict mode
784
+
785
+ Close error unions with explicit unexpected error handling:
786
+
787
+ ```typescript
788
+ const workflow = createWorkflow(
789
+ { riskyOp },
790
+ {
791
+ strict: true,
792
+ catchUnexpected: () => 'UNEXPECTED' as const,
793
+ }
794
+ );
795
+
796
+ // Result type is now 'KNOWN_ERROR' | 'UNEXPECTED' (no UnexpectedError)
797
+ ```
798
+
799
+ ---
800
+
801
+ ## Using them together
802
+
803
+ You don't have to migrate everything at once. Wrap neverthrow Results when you want workflow features:
804
+
805
+ ```typescript
806
+ import { Result as NTResult } from 'neverthrow';
807
+ import { ok, err, type Result } from '@jagreehal/workflow';
808
+
809
+ function fromNeverthrow<T, E>(ntResult: NTResult<T, E>): Result<T, E> {
810
+ return ntResult.isOk() ? ok(ntResult.value) : err(ntResult.error);
811
+ }
812
+
813
+ const result = await run(async (step) => {
814
+ // Your existing neverthrow validation, now with workflow's step() features
815
+ const validated = await step(fromNeverthrow(validateInput(data)));
816
+ return validated;
817
+ });
818
+ ```
819
+
820
+ ---
821
+
822
+ ## Quick comparison tables
823
+
824
+ ### Result API mapping
825
+
826
+ | Operation | neverthrow | @jagreehal/workflow |
827
+ |-----------|------------|---------------------|
828
+ | Result access | `.isOk()`, `.isErr()` methods | `.ok` boolean property |
829
+ | Chaining | `.andThen()` method chains | `step()` with async/await |
830
+ | Wrapping throws | `ResultAsync.fromPromise()` | `fromPromise()`, `tryAsync()` |
831
+ | Parallel ops | `.combine()` | `allAsync()` |
832
+ | Collect all errors | `.combineWithAllErrors()` | `allSettled()` |
833
+ | Pattern matching | `.match(onOk, onErr)` | `match(result, { ok, err })` |
834
+ | Transform value | `.map()` method | `map()` function |
835
+ | Transform error | `.mapErr()` method | `mapError()` function |
836
+ | Transform both | `.bimap()` method | `bimap()` function |
837
+ | Chain results | `.andThen()` method | `andThen()` function |
838
+ | Error recovery | `.orElse()` method | `orElse()` or `recover()` |
839
+
840
+ ### Orchestration features
841
+
842
+ | Feature | neverthrow | @jagreehal/workflow |
843
+ |---------|------------|---------------------|
844
+ | Error inference | Manual union types | Automatic from `createWorkflow` deps |
845
+ | Retries | DIY | Built-in `step.retry()` |
846
+ | Timeouts | DIY | Built-in `step.withTimeout()` |
847
+ | Caching | DIY | Built-in with `key` option |
848
+ | Resume/Persist | DIY | Built-in `resumeState` |
849
+ | Event stream | DIY | Built-in 15+ event types |
850
+ | Visualization | DIY | Built-in ASCII & Mermaid |
851
+ | Saga/Compensation | DIY | Built-in `createSagaWorkflow` |
852
+ | Circuit breaker | DIY | Built-in `createCircuitBreaker` |
853
+
854
+ ---
855
+
856
+ ## Migration checklist
857
+
858
+ Ready to migrate? Here's a practical path:
859
+
860
+ ### Phase 1: Interop (keep existing code)
861
+
862
+ - [ ] Install `@jagreehal/workflow`
863
+ - [ ] Create a `fromNeverthrow()` helper (see "Using them together" above)
864
+ - [ ] Wrap neverthrow Results at integration boundaries when you need workflow features
865
+
866
+ ### Phase 2: New code with workflow
867
+
868
+ - [ ] Use `createWorkflow` for new multi-step operations
869
+ - [ ] Use typed error literals (`'NOT_FOUND' as const`) for narrow unions
870
+ - [ ] Add `key` to steps that need caching or resume
871
+
872
+ ### Phase 3: Add orchestration (as needed)
873
+
874
+ - [ ] Add retries to flaky external calls with `step.retry()`
875
+ - [ ] Add timeouts to slow operations with `step.withTimeout()`
876
+ - [ ] Use `onEvent` for observability and debugging
877
+ - [ ] Consider `createSagaWorkflow` for distributed transactions
878
+
879
+ ### Phase 4: Optional migration of existing code
880
+
881
+ - [ ] Convert neverthrow functions to return `AsyncResult` directly
882
+ - [ ] Replace `ResultAsync.fromPromise()` with `tryAsync()`
883
+ - [ ] Replace `.andThen()` chains with `step()` sequences where clarity improves
884
+
885
+ **Tip:** You don't need to migrate everything. The libraries coexist fine. Migrate when you'd benefit from orchestration features or simpler async flow.
886
+
887
+ ---
888
+
889
+ ## Try it
890
+
891
+ ```bash
892
+ npm install @jagreehal/workflow
893
+ ```
894
+
895
+ ```typescript
896
+ import { createWorkflow, ok, err, type AsyncResult } from '@jagreehal/workflow';
897
+
898
+ const fetchUser = async (id: string): AsyncResult<User, 'NOT_FOUND'> =>
899
+ id === '1' ? ok({ id, name: 'Alice' }) : err('NOT_FOUND');
900
+
901
+ const fetchPosts = async (userId: string): AsyncResult<Post[], 'FETCH_ERROR'> =>
902
+ ok([{ id: 'p1', title: 'Hello World', authorId: userId }]);
903
+
904
+ // Error union inferred automatically
905
+ const loadUserData = createWorkflow({ fetchUser, fetchPosts });
906
+
907
+ const result = await loadUserData(async (step, deps) => {
908
+ const user = await step(deps.fetchUser('1'));
909
+ const posts = await step(deps.fetchPosts(user.id));
910
+ return { user, posts };
911
+ });
912
+
913
+ if (result.ok) {
914
+ console.log(result.value.user.name);
915
+ } else {
916
+ console.log(result.error); // 'NOT_FOUND' | 'FETCH_ERROR' | UnexpectedError
917
+ }
918
+ ```
919
+
920
+ **Next:** [README](../README.md) for the full tutorial, or [Advanced Guide](advanced.md) for production features.