tryharder 0.0.0 → 0.1.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 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)
@@ -55,6 +62,7 @@ const result = await try$
55
62
  - [dispose](#dispose)
56
63
  - [API Reference](#api-reference)
57
64
  - [Common Recipes](#common-recipes)
65
+ - [Limitations](#limitations)
58
66
  - [When not to use tryharder](#when-not-to-use-tryharder)
59
67
  - [Contributing](#contributing)
60
68
  - [Acknowledgments](#acknowledgments)
@@ -62,16 +70,89 @@ const result = await try$
62
70
 
63
71
  </details>
64
72
 
73
+ ## Why not plain try/catch?
74
+
75
+ 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.
76
+
77
+ ```ts
78
+ class UserUnavailableError extends Error {}
79
+
80
+ async function loadUser(signal: AbortSignal) {
81
+ let lastError: unknown
82
+
83
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
84
+ const timeout = AbortSignal.timeout(1_500)
85
+ const combined = AbortSignal.any([signal, timeout])
86
+
87
+ try {
88
+ const user = await db.users.findById("user_123", {
89
+ signal: combined,
90
+ })
91
+
92
+ if (user === null) {
93
+ throw new Error("user not found")
94
+ }
95
+
96
+ return user
97
+ } catch (error) {
98
+ lastError = error
99
+
100
+ if (combined.aborted || attempt === 3) {
101
+ break
102
+ }
103
+ }
104
+ }
105
+
106
+ return new UserUnavailableError("user service unavailable", {
107
+ cause: lastError,
108
+ })
109
+ }
110
+ ```
111
+
112
+ With `tryharder`, the execution policy is declared outside the work and the failure shape becomes part of the returned type:
113
+
114
+ ```ts
115
+ class UserUnavailableError extends Error {}
116
+
117
+ const controller = new AbortController()
118
+
119
+ const result = await try$
120
+ .retry(3)
121
+ .timeout(1_500)
122
+ .signal(controller.signal)
123
+ .run({
124
+ try: async ({ signal }) => {
125
+ const user = await db.users.findById("user_123", { signal })
126
+
127
+ if (user === null) {
128
+ throw new Error("user not found")
129
+ }
130
+
131
+ return user
132
+ },
133
+ catch: () => new UserUnavailableError("user service unavailable"),
134
+ })
135
+
136
+ // result is
137
+ // User | UserUnavailableError | RetryExhaustedError | TimeoutError | CancellationError
138
+ ```
139
+
140
+ That is the core shift:
141
+
142
+ - Plain `try/catch` hides control flow and failure policy inside implementation details.
143
+ - `tryharder` exposes execution policy in the builder chain and failure shape in the return type.
144
+ - `run(fn)` returns `T | UnhandledException`.
145
+ - `run({ try, catch })` returns `T | C`.
146
+ - Adding `retry(...)`, `timeout(...)`, and `signal(...)` widens the union with `RetryExhaustedError`, `TimeoutError`, and `CancellationError`.
147
+
65
148
  ## Features
66
149
 
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`.
150
+ - **Explicit failure unions** - Model thrown failures as values in the returned type instead of an invisible side channel.
151
+ - **Execution policies** - Add retries, total deadlines, and cancellation around a unit of work without rewriting the work itself.
152
+ - **Sync and async parity** - Use the same mental model for `runSync(...)` and `run(...)`.
153
+ - **Named task orchestration** - Express concurrent and ordered workflows with object-shaped task graphs instead of positional arrays.
154
+ - **Observable execution hooks** - Add top-level instrumentation with `wrap(...)` without changing task behavior.
155
+ - **Resource cleanup** - Register teardown that survives async boundaries with `dispose()` and task disposers.
75
156
  - **No runtime dependencies** - The published package ships without runtime dependencies.
76
157
 
77
158
  ## Installation
@@ -106,26 +187,69 @@ import * as try$ from "tryharder"
106
187
 
107
188
  No runtime API names changed.
108
189
 
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` |
190
+ ## Execution Model
191
+
192
+ `tryharder` has three layers: terminal execution APIs, policy builders, and orchestration APIs.
193
+
194
+ 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`.
195
+
196
+ 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.
197
+
198
+ 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(...)`.
199
+
200
+ `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.
201
+
202
+ | Term | Meaning |
203
+ | --------------------- | ------------------------------------------------------------------------------ |
204
+ | `run` | Async terminal execution that returns a value, mapped failure, or policy error |
205
+ | `runSync` | Sync terminal execution for synchronous work only |
206
+ | `retry(limit)` | Retry policy where `limit` includes the first attempt |
207
+ | `timeout(ms)` | Total execution timeout across attempts, delays, and catch handling |
208
+ | `signal(abortSignal)` | External cancellation for `run(...)` and root-level orchestration |
209
+ | `wrap(fn)` | Top-level observational middleware around terminal APIs |
210
+ | `all(tasks)` | Fail-fast parallel named task graph |
211
+ | `allSettled(tasks)` | Settled parallel named task graph |
212
+ | `flow(tasks)` | Ordered task workflow with explicit early exit |
213
+ | `$exit(value)` | Stop a `flow(...)` early and return `value` |
123
214
 
124
215
  Not sure if `tryharder` is a good fit for your project? See [When not to use tryharder](#when-not-to-use-tryharder).
125
216
 
217
+ ## Type Semantics
218
+
219
+ `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.
220
+
221
+ - Domain failures are the values you map yourself with object-form `run({ try, catch })`.
222
+ - Runtime policy failures are introduced by `retry(...)`, `timeout(...)`, and `signal(...)`.
223
+ - Programmer misuse is represented by `Panic`, which is thrown for invalid API usage and invariant violations rather than returned as a domain result.
224
+
225
+ ```ts
226
+ import * as try$ from "tryharder"
227
+
228
+ class ValidationError extends Error {}
229
+
230
+ const result = await try$
231
+ .retry(2)
232
+ .timeout(250)
233
+ .run({
234
+ try: async () => {
235
+ throw new Error("boom")
236
+ },
237
+ catch: () => new ValidationError("invalid input"),
238
+ })
239
+
240
+ // result is
241
+ // ValidationError | RetryExhaustedError | TimeoutError
242
+ ```
243
+
244
+ 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.
245
+
246
+ `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.
247
+
248
+ 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.
249
+
126
250
  ## Quick Start
127
251
 
128
- Use function form when you want thrown failures normalized to `UnhandledException`:
252
+ Use function form when thrown failures should be preserved as `UnhandledException` values:
129
253
 
130
254
  ```ts
131
255
  import * as try$ from "tryharder"
@@ -154,7 +278,7 @@ const result = await try$.run({
154
278
  // ValidationError
155
279
  ```
156
280
 
157
- In a real application, you usually compose policies before the terminal call:
281
+ In practice, you usually declare policy first and execute last:
158
282
 
159
283
  ```ts
160
284
  class UpstreamUnavailableError extends Error {}
@@ -164,23 +288,79 @@ const result = await try$
164
288
  .timeout(1_500)
165
289
  .run({
166
290
  try: async () => {
167
- const response = await fetch("https://example.com/data")
291
+ const account = await db.accounts.findById("acct_123")
168
292
 
169
- if (!response.ok) {
170
- throw new Error("upstream failed")
293
+ if (account === null) {
294
+ throw new Error("account missing")
171
295
  }
172
296
 
173
- return await response.json()
297
+ return account
174
298
  },
175
- catch: () => new UpstreamUnavailableError("data service unavailable"),
299
+ catch: () => new UpstreamUnavailableError("account store unavailable"),
176
300
  })
177
301
  ```
178
302
 
303
+ ## Orchestration Semantics
304
+
305
+ 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.
306
+
307
+ `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`.
308
+
309
+ ```ts
310
+ const result = await try$.all({
311
+ user() {
312
+ return { id: "1", name: "Ada" }
313
+ },
314
+ async profile() {
315
+ const user = await this.$result.user
316
+ return { userId: user.id, plan: "pro" as const }
317
+ },
318
+ })
319
+ ```
320
+
321
+ `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.
322
+
323
+ ```ts
324
+ const settled = await try$.allSettled({
325
+ fail() {
326
+ throw new Error("boom")
327
+ },
328
+ ok() {
329
+ return 1
330
+ },
331
+ })
332
+ ```
333
+
334
+ `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.
335
+
336
+ ```ts
337
+ const result = await try$.flow({
338
+ cache() {
339
+ const cached: string | null = "cached-value"
340
+
341
+ if (cached !== null) {
342
+ return this.$exit(cached)
343
+ }
344
+
345
+ return null
346
+ },
347
+ async api() {
348
+ return "api-value"
349
+ },
350
+ async transform() {
351
+ const value = await this.$result.api
352
+ return this.$exit(`${value}-transformed`)
353
+ },
354
+ })
355
+ ```
356
+
179
357
  ## Usage
180
358
 
181
359
  ### run and runSync
182
360
 
183
- Use function form when you want thrown failures wrapped as `UnhandledException`.
361
+ Use `run(...)` and `runSync(...)` for leaf operations where you want execution and failure semantics attached directly to one function call.
362
+
363
+ Use function form when `UnhandledException` is an acceptable failure value:
184
364
 
185
365
  ```ts
186
366
  const syncValue = try$.runSync(() => 42)
@@ -190,7 +370,7 @@ const asyncValue = await try$.run(async () => {
190
370
  })
191
371
  ```
192
372
 
193
- Use object form when you want to map failures yourself.
373
+ Use object form when you want to map failures into domain results yourself:
194
374
 
195
375
  ```ts
196
376
  class InvalidInputError extends Error {}
@@ -212,7 +392,7 @@ const result = try$.runSync({
212
392
 
213
393
  ### retry, timeout, signal
214
394
 
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`.
395
+ 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
396
 
217
397
  ```ts
218
398
  const controller = new AbortController()
@@ -228,9 +408,11 @@ const result = await try$
228
408
 
229
409
  `timeout(ms)` measures total execution time, not just a single attempt.
230
410
 
411
+ Apply `signal(...)` on the root builder when you want cancellation to cover `all(...)`, `allSettled(...)`, or `flow(...)`.
412
+
231
413
  ### wrap
232
414
 
233
- Use `wrap(...)` for observational middleware around terminal APIs. Wraps are top-level only and can be chained as `.wrap().wrap()`.
415
+ Use `wrap(...)` for logging, tracing, metrics, or other instrumentation that should observe execution without mutating it.
234
416
 
235
417
  ```ts
236
418
  const result = await try$
@@ -242,11 +424,11 @@ const result = await try$
242
424
  .run(async () => "ok")
243
425
  ```
244
426
 
245
- `wrap(...)` is not available after `retry(...)`, `timeout(...)`, or `signal(...)`.
427
+ `wrap(...)` is top-level only and can be chained as `.wrap().wrap()`. It is not available after `retry(...)`, `timeout(...)`, or execution-scoped `signal(...)`.
246
428
 
247
429
  ### all and allSettled
248
430
 
249
- Use `all(...)` for fail-fast task maps and `allSettled(...)` when you want every task result.
431
+ Use `all(...)` and `allSettled(...)` for concurrent work where named tasks and dependency reads are clearer than positional concurrency helpers.
250
432
 
251
433
  ```ts
252
434
  const values = await try$.all({
@@ -273,9 +455,11 @@ const settled = await try$.allSettled({
273
455
  })
274
456
  ```
275
457
 
458
+ Use `all(...)` when you want one successful combined value or one failure path. Use `allSettled(...)` when every outcome should be preserved for inspection.
459
+
276
460
  ### flow and $exit
277
461
 
278
- Use `flow(...)` for dependent pipelines that may short-circuit early. At least one path must call `this.$exit(...)`.
462
+ Use `flow(...)` for procedural workflows where steps depend on prior results and an explicit early return is part of the design.
279
463
 
280
464
  ```ts
281
465
  const result = await try$.flow({
@@ -298,9 +482,11 @@ const result = await try$.flow({
298
482
  })
299
483
  ```
300
484
 
485
+ At least one path must call `this.$exit(...)`. If no task exits, `flow(...)` throws `Panic`.
486
+
301
487
  ### gen
302
488
 
303
- Use `gen(...)` when you want a more linear style over `run(...)` results.
489
+ Use `gen(...)` when the returned unions are correct but nested handling becomes visually noisy and you want a more linear composition style.
304
490
 
305
491
  ```ts
306
492
  const value = await try$.gen(function* (use) {
@@ -312,34 +498,37 @@ const value = await try$.gen(function* (use) {
312
498
 
313
499
  ### dispose
314
500
 
315
- Use `dispose()` to register cleanup for work that spans async boundaries.
501
+ Use `dispose()` when cleanup should stay colocated with the workflow that allocates the resource, even across async boundaries.
316
502
 
317
503
  ```ts
318
504
  await using disposer = try$.dispose()
505
+ const connection = await db.connect()
319
506
 
320
- disposer.defer(() => {
321
- console.log("cleanup")
507
+ disposer.defer(async () => {
508
+ await connection.close()
322
509
  })
510
+
511
+ const user = await connection.users.findById("user_123")
323
512
  ```
324
513
 
325
514
  ## API Reference
326
515
 
327
516
  ### Runtime
328
517
 
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 |
518
+ | Export | Description |
519
+ | -------------- | ------------------------------------------------------------------ |
520
+ | `run` | Async terminal execution API |
521
+ | `runSync` | Sync terminal execution API |
522
+ | `retry` | Create an execution-scoped retry builder |
523
+ | `retryOptions` | Normalize retry policy input |
524
+ | `timeout` | Add a total execution timeout |
525
+ | `signal` | Add external cancellation to execution or root-level orchestration |
526
+ | `wrap` | Add top-level observational middleware |
527
+ | `all` | Run a fail-fast parallel named task graph |
528
+ | `allSettled` | Run a settled parallel named task graph |
529
+ | `flow` | Run an ordered workflow with explicit early exit |
530
+ | `gen` | Compose `run(...)` results through generators |
531
+ | `dispose` | Create an `AsyncDisposableStack` helper |
343
532
 
344
533
  ### Errors
345
534
 
@@ -373,75 +562,123 @@ import type { FlowExit, SettledResult } from "tryharder/types"
373
562
 
374
563
  ## Common Recipes
375
564
 
376
- ### Retry a flaky request with timeout
565
+ ### Map infrastructure failure into domain error
566
+
567
+ Use object-form `run(...)` when transport or infrastructure failures should be normalized into a domain-level result.
377
568
 
378
569
  ```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
- })
570
+ class PaymentUnavailableError extends Error {}
571
+
572
+ const result = await try$.run({
573
+ try: async () => {
574
+ const payment = await db.payments.findById("pay_123")
575
+
576
+ if (payment === null) {
577
+ throw new Error("payment missing")
578
+ }
579
+
580
+ return payment
581
+ },
582
+ catch: () => new PaymentUnavailableError("payments unavailable"),
583
+ })
386
584
  ```
387
585
 
388
- ### Map thrown exceptions into domain results
586
+ ### Retry only the leaf request inside a flow
587
+
588
+ `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
589
 
390
590
  ```ts
391
- class RemoteServiceError extends Error {}
591
+ const result = await try$.flow({
592
+ async fetchUser() {
593
+ const user = await try$.retry(2).run(async () => {
594
+ const row = await db.users.findById("user_123")
392
595
 
393
- const result = await try$.run({
394
- try: async () => {
395
- throw new Error("boom")
596
+ if (row === null) {
597
+ throw new Error("user missing")
598
+ }
599
+
600
+ return row
601
+ })
602
+
603
+ return this.$exit(user)
396
604
  },
397
- catch: () => new RemoteServiceError("service failed"),
398
605
  })
399
606
  ```
400
607
 
401
- ### Run dependent parallel tasks with all
608
+ ### Choose all vs allSettled
609
+
610
+ Use `all(...)` when the workflow should stop on the first failure:
402
611
 
403
612
  ```ts
404
- const result = await try$.all({
405
- async user() {
406
- return { id: "1", name: "Ada" }
613
+ const strict = await try$.all({
614
+ config() {
615
+ return { region: "us-east-1" as const }
407
616
  },
408
- async profile() {
409
- const user = await this.$result.user
410
- return { userId: user.id, plan: "pro" as const }
617
+ async client() {
618
+ const config = await this.$result.config
619
+ return connect(config)
411
620
  },
412
621
  })
413
622
  ```
414
623
 
415
- ### Short-circuit a pipeline with flow
624
+ Use `allSettled(...)` when failure is data you want to inspect:
416
625
 
417
626
  ```ts
418
- const result = await try$.flow({
419
- cache() {
420
- return this.$exit("cached" as const)
627
+ const observed = await try$.allSettled({
628
+ primary() {
629
+ return db.reports.readFromPrimary("daily-active-users")
421
630
  },
422
- async api() {
423
- return "remote"
631
+ replica() {
632
+ return db.reports.readFromReplica("daily-active-users")
424
633
  },
425
634
  })
426
635
  ```
427
636
 
428
- ### Add per-task retry inside orchestration
637
+ ### Use signal at the root for orchestration cancellation
429
638
 
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.
639
+ Root-level `signal(...)` propagates cancellation through orchestration APIs.
431
640
 
432
641
  ```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
- })
642
+ const controller = new AbortController()
439
643
 
440
- return this.$exit(user)
644
+ const result = await try$.signal(controller.signal).all({
645
+ async a() {
646
+ return db.users.findById("user_123", { signal: this.$signal })
647
+ },
648
+ async b() {
649
+ return db.accounts.findById("acct_123", { signal: this.$signal })
441
650
  },
442
651
  })
443
652
  ```
444
653
 
654
+ ### Choose object-form run vs function-form run
655
+
656
+ Use function-form `run(...)` when `UnhandledException` is an acceptable boundary type:
657
+
658
+ ```ts
659
+ const value = await try$.run(async () => {
660
+ return JSON.parse('{"ok":true}')
661
+ })
662
+ ```
663
+
664
+ Use object-form `run(...)` when callers should receive domain-specific failures instead:
665
+
666
+ ```ts
667
+ class InvalidPayloadError extends Error {}
668
+
669
+ const value = await try$.run({
670
+ try: () => JSON.parse("not-json"),
671
+ catch: () => new InvalidPayloadError("payload was invalid"),
672
+ })
673
+ ```
674
+
675
+ ## Limitations
676
+
677
+ - `tryharder` currently assumes `DisposableStack` and `AsyncDisposableStack` are available at runtime because the executor layer uses them internally, not just when you call `dispose()` yourself.
678
+ - This can break consumers on runtimes that do not yet provide those globals, notably Firefox and Safari.
679
+ - The tracked fix is [#36 Bundle DisposableStack polyfill for runtimes without native support](https://github.com/adelrodriguez/tryharder/issues/36).
680
+ - Until that issue is resolved, use `tryharder` on runtimes with native disposable-stack support or provide a compatible polyfill before loading the package.
681
+
445
682
  ## When not to use tryharder
446
683
 
447
684
  - **Small scripts or one-off tasks** - Plain `try/catch` is often simpler when you do not need retries, cancellation, or orchestration.
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-vqm4xgxv.js";
8
8
  export {
9
9
  UnhandledException,
10
10
  TimeoutError,