tryharder 0.0.0 → 0.1.1

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 CHANGED
@@ -11,7 +11,11 @@
11
11
  </p>
12
12
  </div>
13
13
 
14
- Run sync and async work with retries, timeouts, cancellation, typed failure mapping, and orchestration. Use `tryharder` when plain `try/catch` starts to sprawl and you want a small execution API that scales from single calls to parallel task maps and early-exit pipelines.
14
+ `tryharder` is a small execution layer for TypeScript. It keeps plain functions, object-shaped task definitions, and familiar control flow, but makes failure and execution policy explicit in the API surface.
15
+
16
+ Use it when `try/catch` starts absorbing too many concerns at once: retries, deadlines, cancellation, failure mapping, and orchestration. Instead of letting those concerns stay hidden in ambient `throw` paths, `tryharder` returns them as typed values and composes them around a single operation or a task graph.
17
+
18
+ It is deliberately narrower than a full effect runtime. You keep writing normal functions, but the return type tells you which values, mapped failures, and policy-level failures can come back from execution.
15
19
 
16
20
  ```ts
17
21
  import * as try$ from "tryharder"
@@ -19,32 +23,35 @@ import * as try$ from "tryharder"
19
23
  class RequestFailedError extends Error {}
20
24
 
21
25
  const result = await try$
22
- .retry(3) // Retry up to 3 times
23
- .timeout(5_000) // Timeout after 5 seconds
26
+ .retry(3) // Retry up to 3 times total, including the first attempt
27
+ .timeout(5_000) // Enforce one total deadline across attempts
24
28
  .run({
25
29
  try: async () => {
26
- const response = await fetch("https://example.com")
30
+ const order = await db.orders.findById("ord_123")
27
31
 
28
- if (!response.ok) {
29
- throw new Error(`request failed: ${response.status}`)
32
+ if (order === null) {
33
+ throw new Error("order not found")
30
34
  }
31
35
 
32
- return "ok" as const
36
+ return order.status
33
37
  },
34
38
  catch: () => new RequestFailedError("request failed"),
35
39
  })
36
40
 
37
- // result is "ok" | RequestFailedError | RetryExhaustedError | TimeoutError
41
+ // result is OrderStatus | RequestFailedError | RetryExhaustedError | TimeoutError
38
42
  ```
39
43
 
40
44
  <details>
41
45
  <summary>Table of Contents</summary>
42
46
 
47
+ - [Why not plain try/catch?](#why-not-plain-trycatch)
43
48
  - [Features](#features)
44
49
  - [Installation](#installation)
45
50
  - [Migration from hardtry](#migration-from-hardtry)
46
- - [Core Concepts](#core-concepts)
51
+ - [Execution Model](#execution-model)
52
+ - [Type Semantics](#type-semantics)
47
53
  - [Quick Start](#quick-start)
54
+ - [Orchestration Semantics](#orchestration-semantics)
48
55
  - [Usage](#usage)
49
56
  - [run and runSync](#run-and-runsync)
50
57
  - [retry, timeout, signal](#retry-timeout-signal)
@@ -52,7 +59,7 @@ const result = await try$
52
59
  - [all and allSettled](#all-and-allsettled)
53
60
  - [flow and $exit](#flow-and-exit)
54
61
  - [gen](#gen)
55
- - [dispose](#dispose)
62
+ - [dispose](#dispose)
56
63
  - [API Reference](#api-reference)
57
64
  - [Common Recipes](#common-recipes)
58
65
  - [When not to use tryharder](#when-not-to-use-tryharder)
@@ -62,16 +69,89 @@ const result = await try$
62
69
 
63
70
  </details>
64
71
 
72
+ ## Why not plain try/catch?
73
+
74
+ Plain `try/catch` works well for isolated code, but it scales poorly when one block starts carrying retry loops, cancellation wiring, timeout tracking, and domain error mapping at the same time.
75
+
76
+ ```ts
77
+ class UserUnavailableError extends Error {}
78
+
79
+ async function loadUser(signal: AbortSignal) {
80
+ let lastError: unknown
81
+
82
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
83
+ const timeout = AbortSignal.timeout(1_500)
84
+ const combined = AbortSignal.any([signal, timeout])
85
+
86
+ try {
87
+ const user = await db.users.findById("user_123", {
88
+ signal: combined,
89
+ })
90
+
91
+ if (user === null) {
92
+ throw new Error("user not found")
93
+ }
94
+
95
+ return user
96
+ } catch (error) {
97
+ lastError = error
98
+
99
+ if (combined.aborted || attempt === 3) {
100
+ break
101
+ }
102
+ }
103
+ }
104
+
105
+ return new UserUnavailableError("user service unavailable", {
106
+ cause: lastError,
107
+ })
108
+ }
109
+ ```
110
+
111
+ With `tryharder`, the execution policy is declared outside the work and the failure shape becomes part of the returned type:
112
+
113
+ ```ts
114
+ class UserUnavailableError extends Error {}
115
+
116
+ const controller = new AbortController()
117
+
118
+ const result = await try$
119
+ .retry(3)
120
+ .timeout(1_500)
121
+ .signal(controller.signal)
122
+ .run({
123
+ try: async ({ signal }) => {
124
+ const user = await db.users.findById("user_123", { signal })
125
+
126
+ if (user === null) {
127
+ throw new Error("user not found")
128
+ }
129
+
130
+ return user
131
+ },
132
+ catch: () => new UserUnavailableError("user service unavailable"),
133
+ })
134
+
135
+ // result is
136
+ // User | UserUnavailableError | RetryExhaustedError | TimeoutError | CancellationError
137
+ ```
138
+
139
+ That is the core shift:
140
+
141
+ - Plain `try/catch` hides control flow and failure policy inside implementation details.
142
+ - `tryharder` exposes execution policy in the builder chain and failure shape in the return type.
143
+ - `run(fn)` returns `T | UnhandledException`.
144
+ - `run({ try, catch })` returns `T | C`.
145
+ - Adding `retry(...)`, `timeout(...)`, and `signal(...)` widens the union with `RetryExhaustedError`, `TimeoutError`, and `CancellationError`.
146
+
65
147
  ## Features
66
148
 
67
- - **Composable execution policies** - Chain `retry`, `timeout`, and `signal` around work that may fail or be cancelled.
68
- - **Typed failure mapping** - Use object-form `run({ try, catch })` to map thrown errors into domain-specific results.
69
- - **Sync and async entrypoints** - Use `runSync` for synchronous work and `run` for asynchronous work.
70
- - **Task orchestration** - Coordinate named task maps with `all`, `allSettled`, and `flow`.
71
- - **Early exit control** - Short-circuit pipelines with `this.$exit(...)` in `flow`.
72
- - **Middleware-style wrapping** - Observe execution with top-level `wrap(...).wrap(...)` chains.
73
- - **Generator composition** - Build linear workflows over `run(...)` results with `gen(...)`.
74
- - **Resource cleanup** - Register cleanup with `dispose()` and `AsyncDisposableStack`.
149
+ - **Explicit failure unions** - Model thrown failures as values in the returned type instead of an invisible side channel.
150
+ - **Execution policies** - Add retries, total deadlines, and cancellation around a unit of work without rewriting the work itself.
151
+ - **Sync and async parity** - Use the same mental model for `runSync(...)` and `run(...)`.
152
+ - **Named task orchestration** - Express concurrent and ordered workflows with object-shaped task graphs instead of positional arrays.
153
+ - **Observable execution hooks** - Add top-level instrumentation with `wrap(...)` without changing task behavior.
154
+ - **Resource cleanup** - Register teardown that survives async boundaries with `dispose()` and task disposers.
75
155
  - **No runtime dependencies** - The published package ships without runtime dependencies.
76
156
 
77
157
  ## Installation
@@ -106,26 +186,69 @@ import * as try$ from "tryharder"
106
186
 
107
187
  No runtime API names changed.
108
188
 
109
- ## Core Concepts
110
-
111
- | Term | Meaning |
112
- | --------------------- | ------------------------------------------------------------------------- |
113
- | `run` | Async entrypoint that returns a value, a mapped failure, or config error |
114
- | `runSync` | Sync entrypoint for synchronous work only |
115
- | `retry(limit)` | Retry policy where `limit` includes the first attempt |
116
- | `timeout(ms)` | Total execution timeout across attempts, delays, and catch handling |
117
- | `signal(abortSignal)` | External cancellation for `run` and, from the root builder, orchestration |
118
- | `wrap(fn)` | Top-level middleware hook around terminal execution APIs |
119
- | `all(tasks)` | Fail-fast parallel named tasks |
120
- | `allSettled(tasks)` | Settled parallel named tasks |
121
- | `flow(tasks)` | Ordered task orchestration with early exit |
122
- | `$exit(value)` | Stop a `flow` early and return `value` |
189
+ ## Execution Model
190
+
191
+ `tryharder` has three layers: terminal execution APIs, policy builders, and orchestration APIs.
192
+
193
+ Terminal execution APIs are `run(...)` and `runSync(...)`. They are the points where work is actually executed and a result union is produced. Function form is the minimal shape and returns `T | UnhandledException`. Object form adds a `catch` mapper and returns `T | C`.
194
+
195
+ Policy builders decorate terminal execution. `retry(...)`, `timeout(...)`, and `signal(...)` do not run work by themselves; they configure the next terminal call and widen the resulting union with the policy-level failures they can introduce. `retry(limit)` counts the first attempt. `timeout(ms)` applies one total deadline across attempts, delays, and catch handling. `signal(abortSignal)` forwards external cancellation into execution.
196
+
197
+ Orchestration APIs scale the same model from one operation to a task graph. `all(...)` runs a fail-fast named task map. `allSettled(...)` preserves every settled task outcome. `flow(...)` runs an ordered workflow that must explicitly terminate through `this.$exit(...)`.
198
+
199
+ `wrap(...)` sits above those execution APIs as observational middleware. It can inspect readonly execution context and surround terminal calls, but it is not available after `retry(...)`, `timeout(...)`, or execution-scoped `signal(...)` chains. `gen(...)` offers a more linear way to compose returned unions. `dispose()` provides cleanup registration for work that spans async boundaries.
200
+
201
+ | Term | Meaning |
202
+ | --------------------- | ------------------------------------------------------------------------------ |
203
+ | `run` | Async terminal execution that returns a value, mapped failure, or policy error |
204
+ | `runSync` | Sync terminal execution for synchronous work only |
205
+ | `retry(limit)` | Retry policy where `limit` includes the first attempt |
206
+ | `timeout(ms)` | Total execution timeout across attempts, delays, and catch handling |
207
+ | `signal(abortSignal)` | External cancellation for `run(...)` and root-level orchestration |
208
+ | `wrap(fn)` | Top-level observational middleware around terminal APIs |
209
+ | `all(tasks)` | Fail-fast parallel named task graph |
210
+ | `allSettled(tasks)` | Settled parallel named task graph |
211
+ | `flow(tasks)` | Ordered task workflow with explicit early exit |
212
+ | `$exit(value)` | Stop a `flow(...)` early and return `value` |
123
213
 
124
214
  Not sure if `tryharder` is a good fit for your project? See [When not to use tryharder](#when-not-to-use-tryharder).
125
215
 
216
+ ## Type Semantics
217
+
218
+ `tryharder` treats failure as part of the return type. The important distinction is not just that failures are represented as values, but that builder chains preserve which layer introduced them.
219
+
220
+ - Domain failures are the values you map yourself with object-form `run({ try, catch })`.
221
+ - Runtime policy failures are introduced by `retry(...)`, `timeout(...)`, and `signal(...)`.
222
+ - Programmer misuse is represented by `Panic`, which is thrown for invalid API usage and invariant violations rather than returned as a domain result.
223
+
224
+ ```ts
225
+ import * as try$ from "tryharder"
226
+
227
+ class ValidationError extends Error {}
228
+
229
+ const result = await try$
230
+ .retry(2)
231
+ .timeout(250)
232
+ .run({
233
+ try: async () => {
234
+ throw new Error("boom")
235
+ },
236
+ catch: () => new ValidationError("invalid input"),
237
+ })
238
+
239
+ // result is
240
+ // ValidationError | RetryExhaustedError | TimeoutError
241
+ ```
242
+
243
+ That inferred union is the contract. A caller can see whether a function returns a domain error, whether retries may exhaust, and whether a deadline may fire, without reading the implementation body.
244
+
245
+ `Panic` is intentionally separate from that model. It signals programmer errors such as invalid builder usage or invalid task graphs, not expected business-domain failures.
246
+
247
+ One implementation detail worth knowing: `retry(...)` and `timeout(...)` switch the builder onto an execution-only surface. At both the type level and runtime, orchestration methods such as `all(...)`, `allSettled(...)`, `flow(...)`, and `wrap(...)` are not available from those execution-scoped builders. Root-level `signal(...)` still supports orchestration.
248
+
126
249
  ## Quick Start
127
250
 
128
- Use function form when you want thrown failures normalized to `UnhandledException`:
251
+ Use function form when thrown failures should be preserved as `UnhandledException` values:
129
252
 
130
253
  ```ts
131
254
  import * as try$ from "tryharder"
@@ -154,7 +277,7 @@ const result = await try$.run({
154
277
  // ValidationError
155
278
  ```
156
279
 
157
- In a real application, you usually compose policies before the terminal call:
280
+ In practice, you usually declare policy first and execute last:
158
281
 
159
282
  ```ts
160
283
  class UpstreamUnavailableError extends Error {}
@@ -164,23 +287,79 @@ const result = await try$
164
287
  .timeout(1_500)
165
288
  .run({
166
289
  try: async () => {
167
- const response = await fetch("https://example.com/data")
290
+ const account = await db.accounts.findById("acct_123")
168
291
 
169
- if (!response.ok) {
170
- throw new Error("upstream failed")
292
+ if (account === null) {
293
+ throw new Error("account missing")
171
294
  }
172
295
 
173
- return await response.json()
296
+ return account
174
297
  },
175
- catch: () => new UpstreamUnavailableError("data service unavailable"),
298
+ catch: () => new UpstreamUnavailableError("account store unavailable"),
176
299
  })
177
300
  ```
178
301
 
302
+ ## Orchestration Semantics
303
+
304
+ Use `run(...)` and `runSync(...)` for a single unit of work. Use `all(...)` or `allSettled(...)` when you want a concurrent task map with named dependencies. Use `flow(...)` when you need a stepwise workflow with explicit early return.
305
+
306
+ `all(...)` runs an object-shaped task graph and resolves to one object of successful results. Named tasks are easier to scan than positional arrays, and tasks can await earlier task results through `this.$result`. Execution is fail-fast: once one task fails, sibling task signals are aborted and the orchestration rejects unless you provide an orchestration-level `catch`.
307
+
308
+ ```ts
309
+ const result = await try$.all({
310
+ user() {
311
+ return { id: "1", name: "Ada" }
312
+ },
313
+ async profile() {
314
+ const user = await this.$result.user
315
+ return { userId: user.id, plan: "pro" as const }
316
+ },
317
+ })
318
+ ```
319
+
320
+ `allSettled(...)` uses the same task-graph shape, but preserves every task outcome as settled data. Use it when failure is expected input to the next decision rather than something that should short-circuit the whole graph.
321
+
322
+ ```ts
323
+ const settled = await try$.allSettled({
324
+ fail() {
325
+ throw new Error("boom")
326
+ },
327
+ ok() {
328
+ return 1
329
+ },
330
+ })
331
+ ```
332
+
333
+ `flow(...)` is for dependent business-process style workflows. Tasks still read through `this.$result`, but completion is explicit: at least one path must call `this.$exit(...)`. That makes early return a visible part of the workflow contract instead of an implicit convention.
334
+
335
+ ```ts
336
+ const result = await try$.flow({
337
+ cache() {
338
+ const cached: string | null = "cached-value"
339
+
340
+ if (cached !== null) {
341
+ return this.$exit(cached)
342
+ }
343
+
344
+ return null
345
+ },
346
+ async api() {
347
+ return "api-value"
348
+ },
349
+ async transform() {
350
+ const value = await this.$result.api
351
+ return this.$exit(`${value}-transformed`)
352
+ },
353
+ })
354
+ ```
355
+
179
356
  ## Usage
180
357
 
181
358
  ### run and runSync
182
359
 
183
- Use function form when you want thrown failures wrapped as `UnhandledException`.
360
+ Use `run(...)` and `runSync(...)` for leaf operations where you want execution and failure semantics attached directly to one function call.
361
+
362
+ Use function form when `UnhandledException` is an acceptable failure value:
184
363
 
185
364
  ```ts
186
365
  const syncValue = try$.runSync(() => 42)
@@ -190,7 +369,7 @@ const asyncValue = await try$.run(async () => {
190
369
  })
191
370
  ```
192
371
 
193
- Use object form when you want to map failures yourself.
372
+ Use object form when you want to map failures into domain results yourself:
194
373
 
195
374
  ```ts
196
375
  class InvalidInputError extends Error {}
@@ -212,7 +391,7 @@ const result = try$.runSync({
212
391
 
213
392
  ### retry, timeout, signal
214
393
 
215
- Use modifiers to add retry, total timeout, and cancellation around `run(...)`. `signal(...)` can also be applied at the root builder before `all`, `allSettled`, or `flow`.
394
+ Use these when execution policy belongs around a single unit of work. They decorate `run(...)` or `runSync(...)`, widen the returned union, and keep policy separate from business logic.
216
395
 
217
396
  ```ts
218
397
  const controller = new AbortController()
@@ -228,9 +407,11 @@ const result = await try$
228
407
 
229
408
  `timeout(ms)` measures total execution time, not just a single attempt.
230
409
 
410
+ Apply `signal(...)` on the root builder when you want cancellation to cover `all(...)`, `allSettled(...)`, or `flow(...)`.
411
+
231
412
  ### wrap
232
413
 
233
- Use `wrap(...)` for observational middleware around terminal APIs. Wraps are top-level only and can be chained as `.wrap().wrap()`.
414
+ Use `wrap(...)` for logging, tracing, metrics, or other instrumentation that should observe execution without mutating it.
234
415
 
235
416
  ```ts
236
417
  const result = await try$
@@ -242,11 +423,11 @@ const result = await try$
242
423
  .run(async () => "ok")
243
424
  ```
244
425
 
245
- `wrap(...)` is not available after `retry(...)`, `timeout(...)`, or `signal(...)`.
426
+ `wrap(...)` is top-level only and can be chained as `.wrap().wrap()`. It is not available after `retry(...)`, `timeout(...)`, or execution-scoped `signal(...)`.
246
427
 
247
428
  ### all and allSettled
248
429
 
249
- Use `all(...)` for fail-fast task maps and `allSettled(...)` when you want every task result.
430
+ Use `all(...)` and `allSettled(...)` for concurrent work where named tasks and dependency reads are clearer than positional concurrency helpers.
250
431
 
251
432
  ```ts
252
433
  const values = await try$.all({
@@ -273,9 +454,11 @@ const settled = await try$.allSettled({
273
454
  })
274
455
  ```
275
456
 
457
+ Use `all(...)` when you want one successful combined value or one failure path. Use `allSettled(...)` when every outcome should be preserved for inspection.
458
+
276
459
  ### flow and $exit
277
460
 
278
- Use `flow(...)` for dependent pipelines that may short-circuit early. At least one path must call `this.$exit(...)`.
461
+ Use `flow(...)` for procedural workflows where steps depend on prior results and an explicit early return is part of the design.
279
462
 
280
463
  ```ts
281
464
  const result = await try$.flow({
@@ -298,9 +481,11 @@ const result = await try$.flow({
298
481
  })
299
482
  ```
300
483
 
484
+ At least one path must call `this.$exit(...)`. If no task exits, `flow(...)` throws `Panic`.
485
+
301
486
  ### gen
302
487
 
303
- Use `gen(...)` when you want a more linear style over `run(...)` results.
488
+ Use `gen(...)` when the returned unions are correct but nested handling becomes visually noisy and you want a more linear composition style.
304
489
 
305
490
  ```ts
306
491
  const value = await try$.gen(function* (use) {
@@ -312,34 +497,46 @@ const value = await try$.gen(function* (use) {
312
497
 
313
498
  ### dispose
314
499
 
315
- Use `dispose()` to register cleanup for work that spans async boundaries.
500
+ Use `dispose()` when cleanup should stay colocated with the workflow that allocates the resource, even across async boundaries. The returned `AsyncDisposer` gives you three core operations:
501
+
502
+ - `add(fn)` registers a cleanup callback.
503
+ - `use(resource)` tracks a disposable resource.
504
+ - `cleanup()` runs the registered teardown in reverse order.
316
505
 
317
506
  ```ts
318
507
  await using disposer = try$.dispose()
319
508
 
320
- disposer.defer(() => {
321
- console.log("cleanup")
322
- })
509
+ {
510
+ const connection = await db.connect()
511
+
512
+ disposer.add(async () => {
513
+ await connection.close()
514
+ })
515
+
516
+ const user = await connection.users.findById("user_123")
517
+ }
323
518
  ```
324
519
 
520
+ `tryharder` handles the cleanup bookkeeping internally, so native `DisposableStack` or `AsyncDisposableStack` globals are not required.
521
+
325
522
  ## API Reference
326
523
 
327
524
  ### Runtime
328
525
 
329
- | Export | Description |
330
- | -------------- | ------------------------------------------- |
331
- | `run` | Async execution entrypoint |
332
- | `runSync` | Sync execution entrypoint |
333
- | `retry` | Create a retry policy builder |
334
- | `retryOptions` | Normalize retry policy input |
335
- | `timeout` | Add a total execution timeout |
336
- | `signal` | Add external cancellation |
337
- | `wrap` | Add top-level middleware hooks |
338
- | `all` | Run fail-fast parallel named tasks |
339
- | `allSettled` | Run settled parallel named tasks |
340
- | `flow` | Run ordered tasks with early exit |
341
- | `gen` | Compose `run(...)` calls through generators |
342
- | `dispose` | Create an `AsyncDisposableStack` helper |
526
+ | Export | Description |
527
+ | -------------- | ----------------------------------------------------------------------- |
528
+ | `run` | Async terminal execution API |
529
+ | `runSync` | Sync terminal execution API |
530
+ | `retry` | Create an execution-scoped retry builder |
531
+ | `retryOptions` | Normalize retry policy input |
532
+ | `timeout` | Add a total execution timeout |
533
+ | `signal` | Add external cancellation to execution or root-level orchestration |
534
+ | `wrap` | Add top-level observational middleware |
535
+ | `all` | Run a fail-fast parallel named task graph |
536
+ | `allSettled` | Run a settled parallel named task graph |
537
+ | `flow` | Run an ordered workflow with explicit early exit |
538
+ | `gen` | Compose `run(...)` results through generators |
539
+ | `dispose` | Create an `AsyncDisposer` helper with `add()`, `use()`, and `cleanup()` |
343
540
 
344
541
  ### Errors
345
542
 
@@ -360,6 +557,7 @@ Exports from `tryharder/types`:
360
557
  | Export | Description |
361
558
  | ------------------ | ---------------------------------------------------- |
362
559
  | `AllSettledResult` | Settled result map returned by `allSettled(...)` |
560
+ | `AsyncDisposer` | Async cleanup helper returned by `dispose()` |
363
561
  | `SettledFulfilled` | Fulfilled branch of a settled task result |
364
562
  | `SettledRejected` | Rejected branch of a settled task result |
365
563
  | `SettledResult` | Union of fulfilled and rejected settled task results |
@@ -368,87 +566,126 @@ Exports from `tryharder/types`:
368
566
  ```ts
369
567
  import * as try$ from "tryharder"
370
568
  import { Panic, TimeoutError, UnhandledException } from "tryharder/errors"
371
- import type { FlowExit, SettledResult } from "tryharder/types"
569
+ import type { AsyncDisposer, FlowExit, SettledResult } from "tryharder/types"
372
570
  ```
373
571
 
374
572
  ## Common Recipes
375
573
 
376
- ### Retry a flaky request with timeout
574
+ ### Map infrastructure failure into domain error
575
+
576
+ Use object-form `run(...)` when transport or infrastructure failures should be normalized into a domain-level result.
377
577
 
378
578
  ```ts
379
- const result = await try$
380
- .retry({ backoff: "constant", delayMs: 200, limit: 3 })
381
- .timeout(2_000)
382
- .run(async () => {
383
- const response = await fetch("https://example.com")
384
- return response.text()
385
- })
579
+ class PaymentUnavailableError extends Error {}
580
+
581
+ const result = await try$.run({
582
+ try: async () => {
583
+ const payment = await db.payments.findById("pay_123")
584
+
585
+ if (payment === null) {
586
+ throw new Error("payment missing")
587
+ }
588
+
589
+ return payment
590
+ },
591
+ catch: () => new PaymentUnavailableError("payments unavailable"),
592
+ })
386
593
  ```
387
594
 
388
- ### Map thrown exceptions into domain results
595
+ ### Retry only the leaf request inside a flow
596
+
597
+ `retry(...)` and `timeout(...)` do not apply directly to `flow(...)`. Wrap the leaf work in nested `run(...)` calls when a single step needs its own execution policy.
389
598
 
390
599
  ```ts
391
- class RemoteServiceError extends Error {}
600
+ const result = await try$.flow({
601
+ async fetchUser() {
602
+ const user = await try$.retry(2).run(async () => {
603
+ const row = await db.users.findById("user_123")
392
604
 
393
- const result = await try$.run({
394
- try: async () => {
395
- throw new Error("boom")
605
+ if (row === null) {
606
+ throw new Error("user missing")
607
+ }
608
+
609
+ return row
610
+ })
611
+
612
+ return this.$exit(user)
396
613
  },
397
- catch: () => new RemoteServiceError("service failed"),
398
614
  })
399
615
  ```
400
616
 
401
- ### Run dependent parallel tasks with all
617
+ ### Choose all vs allSettled
618
+
619
+ Use `all(...)` when the workflow should stop on the first failure:
402
620
 
403
621
  ```ts
404
- const result = await try$.all({
405
- async user() {
406
- return { id: "1", name: "Ada" }
622
+ const strict = await try$.all({
623
+ config() {
624
+ return { region: "us-east-1" as const }
407
625
  },
408
- async profile() {
409
- const user = await this.$result.user
410
- return { userId: user.id, plan: "pro" as const }
626
+ async client() {
627
+ const config = await this.$result.config
628
+ return connect(config)
411
629
  },
412
630
  })
413
631
  ```
414
632
 
415
- ### Short-circuit a pipeline with flow
633
+ Use `allSettled(...)` when failure is data you want to inspect:
416
634
 
417
635
  ```ts
418
- const result = await try$.flow({
419
- cache() {
420
- return this.$exit("cached" as const)
636
+ const observed = await try$.allSettled({
637
+ primary() {
638
+ return db.reports.readFromPrimary("daily-active-users")
421
639
  },
422
- async api() {
423
- return "remote"
640
+ replica() {
641
+ return db.reports.readFromReplica("daily-active-users")
424
642
  },
425
643
  })
426
644
  ```
427
645
 
428
- ### Add per-task retry inside orchestration
646
+ ### Use signal at the root for orchestration cancellation
429
647
 
430
- `retry(...)` and `timeout(...)` apply to `run(...)` and `runSync(...)`, not directly to `all(...)` or `flow(...)`. When you need per-task policies, wrap leaf work in nested `run(...)` calls.
648
+ Root-level `signal(...)` propagates cancellation through orchestration APIs.
431
649
 
432
650
  ```ts
433
- const result = await try$.flow({
434
- async fetchUser() {
435
- const user = await try$.retry(2).run(async () => {
436
- const response = await fetch("https://example.com/user")
437
- return await response.json()
438
- })
651
+ const controller = new AbortController()
439
652
 
440
- return this.$exit(user)
653
+ const result = await try$.signal(controller.signal).all({
654
+ async a() {
655
+ return db.users.findById("user_123", { signal: this.$signal })
656
+ },
657
+ async b() {
658
+ return db.accounts.findById("acct_123", { signal: this.$signal })
441
659
  },
442
660
  })
443
661
  ```
444
662
 
445
- ## When not to use tryharder
663
+ ### Choose object-form run vs function-form run
664
+
665
+ Use function-form `run(...)` when `UnhandledException` is an acceptable boundary type:
666
+
667
+ ```ts
668
+ const value = await try$.run(async () => {
669
+ return JSON.parse('{"ok":true}')
670
+ })
671
+ ```
672
+
673
+ Use object-form `run(...)` when callers should receive domain-specific failures instead:
674
+
675
+ ```ts
676
+ class InvalidPayloadError extends Error {}
677
+
678
+ const value = await try$.run({
679
+ try: () => JSON.parse("not-json"),
680
+ catch: () => new InvalidPayloadError("payload was invalid"),
681
+ })
682
+ ```
683
+
684
+ ## When not to use
685
+
686
+ When you can use [`Effect`](https://github.com/Effect-TS/effect) in your codebase.
446
687
 
447
- - **Small scripts or one-off tasks** - Plain `try/catch` is often simpler when you do not need retries, cancellation, or orchestration.
448
- - **You already use an effect system or result abstraction** - If your codebase already has a consistent execution model, adding `tryharder` may be redundant.
449
- - **Your workflows are mostly straightforward Promise chains** - `all(...)` and `flow(...)` help when coordination matters; otherwise native composition may be clearer.
450
- - **Your team prefers explicit `Result` values everywhere** - `tryharder` centers execution wrappers, not a dedicated result data type.
451
- - **You do not want policy-driven execution behavior** - If retry and timeout semantics are unnecessary overhead, the abstraction may not pay for itself.
688
+ Seriously, Effect is a much more powerful and complete solution.
452
689
 
453
690
  ## Contributing
454
691
 
package/dist/errors.js CHANGED
@@ -4,7 +4,7 @@ import {
4
4
  RetryExhaustedError,
5
5
  TimeoutError,
6
6
  UnhandledException
7
- } from "./shared/chunk-996eswzx.js";
7
+ } from "./shared/chunk-vavcyath.js";
8
8
  export {
9
9
  UnhandledException,
10
10
  TimeoutError,