@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.
- package/README.md +664 -332
- package/dist/core.cjs +1 -1
- package/dist/core.cjs.map +1 -1
- package/dist/core.d.cts +179 -1
- package/dist/core.d.ts +179 -1
- package/dist/core.js +1 -1
- package/dist/core.js.map +1 -1
- package/dist/index.cjs +9 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +3266 -2
- package/dist/index.d.ts +3266 -2
- package/dist/index.js +9 -1
- package/dist/index.js.map +1 -1
- package/dist/visualize.cjs +6 -6
- package/dist/visualize.cjs.map +1 -1
- package/dist/visualize.js +6 -6
- package/dist/visualize.js.map +1 -1
- package/dist/workflow.cjs +1 -1
- package/dist/workflow.cjs.map +1 -1
- package/dist/workflow.js +1 -1
- package/dist/workflow.js.map +1 -1
- package/docs/advanced.md +895 -0
- package/docs/api.md +257 -0
- package/docs/coming-from-neverthrow.md +920 -0
- package/docs/visualize-examples.md +330 -0
- package/package.json +7 -6
|
@@ -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.
|