@nlozgachev/pipelined 0.19.0 → 0.20.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
@@ -10,15 +10,83 @@ Opinionated functional abstractions for TypeScript.
10
10
  npm add @nlozgachev/pipelined
11
11
  ```
12
12
 
13
- ## What is this?
13
+ ## Possibly maybe
14
14
 
15
- A toolkit for expressing uncertainty precisely. Instead of `T | null`, `try/catch`, and loading
16
- state flag soup, you get types that name every possible state and make invalid ones unrepresentable.
17
- Each type comes with a consistent set of operations `map`, `chain`, `match`, `getOrElse` that
18
- compose with `pipe` and `flow`.
15
+ **pipelined** names every possible state and gives you operations that compose. `Maybe<A>` for values
16
+ that may or may not be there. `Result<E, A>` for operations that succeed or fail
17
+ with a typed error. `TaskResult<E, A>` for async operations that do both lazily, with retry,
18
+ timeout, and cancellation built in. And, of course, there is more than that.
19
+
20
+ ## Documentation
21
+
22
+ Full guides and API reference at **[pipelined.lozgachev.dev](https://pipelined.lozgachev.dev)**.
23
+
24
+ ## Example
25
+
26
+ The standard approach to "fetch with retry and timeout":
27
+
28
+ ```ts
29
+ async function fetchUser(id: string, signal?: AbortSignal): Promise<User> {
30
+ let lastError: unknown;
31
+ for (let attempt = 1; attempt <= 3; attempt++) {
32
+ try {
33
+ const res = await fetch(`/users/${id}`, { signal });
34
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
35
+ return await res.json();
36
+ } catch (e) {
37
+ if (signal?.aborted) throw e;
38
+ lastError = e;
39
+ if (attempt < 3) await new Promise(r => setTimeout(r, attempt * 1000));
40
+ }
41
+ }
42
+ throw lastError;
43
+ }
44
+ ```
45
+
46
+ The caller receives a `Promise<User>`. What it rejects with is `unknown`. The signal is checked by
47
+ hand. The timeout is missing. The retry loop is inlined and will need to be rewritten for the next
48
+ endpoint too.
49
+
50
+ With **pipelined**:
51
+
52
+ ```ts
53
+ import { TaskResult } from "@nlozgachev/pipelined/core";
54
+ import { pipe } from "@nlozgachev/pipelined/composition";
55
+
56
+ const fetchUser = (id: string): TaskResult<ApiError, User> =>
57
+ pipe(
58
+ TaskResult.tryCatch(
59
+ (signal) => fetch(`/users/${id}`, { signal }).then(r => r.json()),
60
+ (e) => new ApiError(e),
61
+ ),
62
+ TaskResult.timeout(5000, () => new ApiError("request timed out")),
63
+ TaskResult.retry({ attempts: 3, backoff: (n) => n * 1000 }),
64
+ );
65
+ ```
66
+
67
+ `TaskResult<ApiError, User>` is a lazy function — nothing runs until called. The `AbortSignal`
68
+ threads through every retry and the timeout automatically. The return type is the contract:
69
+ `ApiError` on the left, `User` on the right, nothing escapes as an exception.
70
+
71
+ ```ts
72
+ const controller = new AbortController();
73
+ const result = await fetchUser("42")(controller.signal);
74
+
75
+ if (Result.isOk(result)) {
76
+ render(result.value); // User
77
+ } else {
78
+ showError(result.error.message); // ApiError, not unknown
79
+ }
80
+ ```
19
81
 
20
82
  ## What's included?
21
83
 
84
+ `TaskResult` is one type. The library also covers the rest of the states you encounter in real
85
+ applications: values that may be absent, operations that accumulate multiple errors, data that moves
86
+ through `NotAsked >> Loading >> ( Success | Failure )`, nested immutable updates, and computations that
87
+ share a common environment. Every type follows the same conventions — `map`, `chain`, `match`,
88
+ `getOrElse` — so moving between them feels familiar.
89
+
22
90
  ### pipelined/core
23
91
 
24
92
  - **`Maybe<A>`** — a value that may not exist; propagates absence without null checks.
@@ -45,9 +113,16 @@ Everyday utilities for built-in JS types.
45
113
 
46
114
  - **`Arr`** — array utilities, data-last, returning `Maybe` instead of `undefined`.
47
115
  - **`Rec`** — record/object utilities, data-last, with `Maybe`-returning key lookup.
116
+ - **`Dict`** — `ReadonlyMap<K, V>` utilities: `lookup`, `groupBy`, `upsert`, set operations.
117
+ - **`Uniq`** — `ReadonlySet<A>` utilities: `insert`, `remove`, `union`, `intersection`, `difference`.
48
118
  - **`Num`** — number utilities: `range`, `clamp`, `between`, safe `parse`, and curried arithmetic.
49
119
  - **`Str`** — string utilities: `split`, `trim`, `words`, `lines`, and safe `parse.int` / `parse.float`.
50
120
 
121
+ Every utility is benchmarked against its native equivalent. The data-last currying adds a function
122
+ call; that is the expected cost of composability. Operations that exceeded a reasonable overhead
123
+ have custom implementations that in several cases run faster than the native method they replace. See the
124
+ [benchmarks page](https://pipelined.lozgachev.dev/appendix/benchmarks) for the methodology.
125
+
51
126
  ### pipelined/types
52
127
 
53
128
  - **`Brand<K, T>`** — nominal typing at compile time, zero runtime cost.
@@ -58,33 +133,8 @@ Everyday utilities for built-in JS types.
58
133
  - **`pipe`**, **`flow`**, **`compose`** — function composition.
59
134
  - **`curry`** / **`uncurry`**, **`tap`**, **`memoize`**, and other function utilities.
60
135
 
61
- ## Example
62
136
 
63
- ```ts
64
- import { Maybe, Result } from "@nlozgachev/pipelined/core";
65
- import { pipe } from "@nlozgachev/pipelined/composition";
66
-
67
- // Chain nullable lookups without nested null checks
68
- const city = pipe(
69
- getUser(userId), // User | null
70
- Maybe.fromNullable, // Maybe<User>
71
- Maybe.chain((u) => Maybe.fromNullable(u.address)), // Maybe<Address>
72
- Maybe.chain((a) => Maybe.fromNullable(a.city)), // Maybe<string>
73
- Maybe.map((c) => c.toUpperCase()), // Maybe<string>
74
- Maybe.getOrElse("UNKNOWN"), // string
75
- );
76
-
77
- // Parse input and look up a record — both steps can fail
78
- const record = pipe(
79
- parseId(rawInput), // Result<ParseError, number>
80
- Result.chain((id) => db.find(id)), // Result<ParseError | NotFoundError, Record>
81
- Result.map((r) => r.name), // Result<ParseError | NotFoundError, string>
82
- );
83
- ```
84
137
 
85
- ## Documentation
86
-
87
- Full guides and API reference at **[pipelined.lozgachev.dev](https://pipelined.lozgachev.dev)**.
88
138
 
89
139
  ## License
90
140
 
@@ -465,6 +465,10 @@ declare namespace Maybe {
465
465
  * - **Infallible** — it never rejects. If failure is possible, encode it in the
466
466
  * return type using `TaskResult<E, A>` instead.
467
467
  *
468
+ * An optional `AbortSignal` can be passed at the call site. Combinators like
469
+ * `retry`, `pollUntil`, and `timeout` thread it automatically to every inner
470
+ * operation. Existing tasks that ignore the signal continue to work unchanged.
471
+ *
468
472
  * Calling a Task returns a `Deferred<A>` — a one-shot async value that supports
469
473
  * `await` but has no `.catch()`, `.finally()`, or chainable `.then()`.
470
474
  *
@@ -495,7 +499,7 @@ declare namespace Maybe {
495
499
  * const result = await formatted();
496
500
  * ```
497
501
  */
498
- type Task<A> = () => Deferred<A>;
502
+ type Task<A> = (signal?: AbortSignal) => Deferred<A>;
499
503
  declare namespace Task {
500
504
  /**
501
505
  * Creates a Task that immediately resolves to the given value.
@@ -509,13 +513,14 @@ declare namespace Task {
509
513
  const resolve: <A>(value: A) => Task<A>;
510
514
  /**
511
515
  * Creates a Task from a function that returns a Promise.
516
+ * The factory optionally receives an `AbortSignal` forwarded from the call site.
512
517
  *
513
518
  * @example
514
519
  * ```ts
515
520
  * const getTimestamp = Task.from(() => Promise.resolve(Date.now()));
516
521
  * ```
517
522
  */
518
- const from: <A>(f: () => Promise<A>) => Task<A>;
523
+ const from: <A>(f: (signal?: AbortSignal) => Promise<A>) => Task<A>;
519
524
  /**
520
525
  * Transforms the value inside a Task.
521
526
  *
@@ -660,7 +665,9 @@ declare namespace Task {
660
665
  const sequential: <A>(tasks: ReadonlyArray<Task<A>>) => Task<ReadonlyArray<A>>;
661
666
  /**
662
667
  * Converts a `Task<A>` into a `Task<Result<E, A>>`, resolving to `Err` if the
663
- * Task does not complete within the given time.
668
+ * Task does not complete within the given time. The inner Task receives an
669
+ * `AbortSignal` that fires when the deadline passes, so operations like `fetch`
670
+ * that accept a signal are cancelled rather than left dangling.
664
671
  *
665
672
  * @example
666
673
  * ```ts
@@ -672,6 +679,28 @@ declare namespace Task {
672
679
  * ```
673
680
  */
674
681
  const timeout: <E>(ms: number, onTimeout: () => E) => <A>(task: Task<A>) => Task<Result<E, A>>;
682
+ /**
683
+ * Creates a Task paired with an `abort` handle. When `abort()` is called the
684
+ * `AbortSignal` passed to the factory is fired, cancelling any in-flight
685
+ * operation (e.g. a `fetch`) immediately.
686
+ *
687
+ * If an outer signal is also present (passed at the call site), aborting it
688
+ * propagates into the internal controller.
689
+ *
690
+ * @example
691
+ * ```ts
692
+ * const { task: poll, abort } = Task.abortable(
693
+ * (signal) => waitForEvent(bus, "ready", { signal }),
694
+ * );
695
+ *
696
+ * onUnmount(abort);
697
+ * await poll();
698
+ * ```
699
+ */
700
+ const abortable: <A>(factory: (signal: AbortSignal) => Promise<A>) => {
701
+ task: Task<A>;
702
+ abort: () => void;
703
+ };
675
704
  }
676
705
 
677
706
  export { Deferred as D, type Err as E, Maybe as M, type None as N, type Ok as O, Result as R, type Some as S, Task as T, type WithValue as W, type WithLog as a, type WithKind as b, type WithError as c, type WithErrors as d, type WithFirst as e, type WithSecond as f };
@@ -465,6 +465,10 @@ declare namespace Maybe {
465
465
  * - **Infallible** — it never rejects. If failure is possible, encode it in the
466
466
  * return type using `TaskResult<E, A>` instead.
467
467
  *
468
+ * An optional `AbortSignal` can be passed at the call site. Combinators like
469
+ * `retry`, `pollUntil`, and `timeout` thread it automatically to every inner
470
+ * operation. Existing tasks that ignore the signal continue to work unchanged.
471
+ *
468
472
  * Calling a Task returns a `Deferred<A>` — a one-shot async value that supports
469
473
  * `await` but has no `.catch()`, `.finally()`, or chainable `.then()`.
470
474
  *
@@ -495,7 +499,7 @@ declare namespace Maybe {
495
499
  * const result = await formatted();
496
500
  * ```
497
501
  */
498
- type Task<A> = () => Deferred<A>;
502
+ type Task<A> = (signal?: AbortSignal) => Deferred<A>;
499
503
  declare namespace Task {
500
504
  /**
501
505
  * Creates a Task that immediately resolves to the given value.
@@ -509,13 +513,14 @@ declare namespace Task {
509
513
  const resolve: <A>(value: A) => Task<A>;
510
514
  /**
511
515
  * Creates a Task from a function that returns a Promise.
516
+ * The factory optionally receives an `AbortSignal` forwarded from the call site.
512
517
  *
513
518
  * @example
514
519
  * ```ts
515
520
  * const getTimestamp = Task.from(() => Promise.resolve(Date.now()));
516
521
  * ```
517
522
  */
518
- const from: <A>(f: () => Promise<A>) => Task<A>;
523
+ const from: <A>(f: (signal?: AbortSignal) => Promise<A>) => Task<A>;
519
524
  /**
520
525
  * Transforms the value inside a Task.
521
526
  *
@@ -660,7 +665,9 @@ declare namespace Task {
660
665
  const sequential: <A>(tasks: ReadonlyArray<Task<A>>) => Task<ReadonlyArray<A>>;
661
666
  /**
662
667
  * Converts a `Task<A>` into a `Task<Result<E, A>>`, resolving to `Err` if the
663
- * Task does not complete within the given time.
668
+ * Task does not complete within the given time. The inner Task receives an
669
+ * `AbortSignal` that fires when the deadline passes, so operations like `fetch`
670
+ * that accept a signal are cancelled rather than left dangling.
664
671
  *
665
672
  * @example
666
673
  * ```ts
@@ -672,6 +679,28 @@ declare namespace Task {
672
679
  * ```
673
680
  */
674
681
  const timeout: <E>(ms: number, onTimeout: () => E) => <A>(task: Task<A>) => Task<Result<E, A>>;
682
+ /**
683
+ * Creates a Task paired with an `abort` handle. When `abort()` is called the
684
+ * `AbortSignal` passed to the factory is fired, cancelling any in-flight
685
+ * operation (e.g. a `fetch`) immediately.
686
+ *
687
+ * If an outer signal is also present (passed at the call site), aborting it
688
+ * propagates into the internal controller.
689
+ *
690
+ * @example
691
+ * ```ts
692
+ * const { task: poll, abort } = Task.abortable(
693
+ * (signal) => waitForEvent(bus, "ready", { signal }),
694
+ * );
695
+ *
696
+ * onUnmount(abort);
697
+ * await poll();
698
+ * ```
699
+ */
700
+ const abortable: <A>(factory: (signal: AbortSignal) => Promise<A>) => {
701
+ task: Task<A>;
702
+ abort: () => void;
703
+ };
675
704
  }
676
705
 
677
706
  export { Deferred as D, type Err as E, Maybe as M, type None as N, type Ok as O, Result as R, type Some as S, Task as T, type WithValue as W, type WithLog as a, type WithKind as b, type WithError as c, type WithErrors as d, type WithFirst as e, type WithSecond as f };
@@ -3,7 +3,7 @@ import {
3
3
  Maybe,
4
4
  Result,
5
5
  Task
6
- } from "./chunk-EAR4TIGH.mjs";
6
+ } from "./chunk-RUDOUVQR.mjs";
7
7
 
8
8
  // src/Core/Lens.ts
9
9
  var Lens;
@@ -312,12 +312,23 @@ var TaskMaybe;
312
312
  })(TaskMaybe || (TaskMaybe = {}));
313
313
 
314
314
  // src/Core/TaskResult.ts
315
+ var cancellableWait = (ms, signal) => {
316
+ if (ms <= 0) return Promise.resolve();
317
+ if (!signal) return new Promise((r) => setTimeout(r, ms));
318
+ return new Promise((resolve) => {
319
+ const id = setTimeout(resolve, ms);
320
+ signal.addEventListener("abort", () => {
321
+ clearTimeout(id);
322
+ resolve();
323
+ }, { once: true });
324
+ });
325
+ };
315
326
  var TaskResult;
316
327
  ((TaskResult2) => {
317
328
  TaskResult2.ok = (value) => Task.resolve(Result.ok(value));
318
329
  TaskResult2.err = (error) => Task.resolve(Result.err(error));
319
330
  TaskResult2.tryCatch = (f, onError) => Task.from(
320
- () => f().then(Result.ok).catch((e) => Result.err(onError(e)))
331
+ (signal) => f(signal).then(Result.ok).catch((e) => Result.err(onError(e)))
321
332
  );
322
333
  TaskResult2.map = (f) => (data) => Task.map(Result.map(f))(data);
323
334
  TaskResult2.mapError = (f) => (data) => Task.map(Result.mapError(f))(data);
@@ -331,43 +342,75 @@ var TaskResult;
331
342
  )(data);
332
343
  TaskResult2.getOrElse = (defaultValue) => (data) => Task.map(Result.getOrElse(defaultValue))(data);
333
344
  TaskResult2.tap = (f) => (data) => Task.map(Result.tap(f))(data);
334
- TaskResult2.retry = (options) => (data) => Task.from(() => {
345
+ TaskResult2.retry = (options) => (data) => Task.from((signal) => {
335
346
  const { attempts, backoff, when: shouldRetry } = options;
336
347
  const getDelay = (n) => backoff === void 0 ? 0 : typeof backoff === "function" ? backoff(n) : backoff;
337
- const run = (left) => Deferred.toPromise(data()).then((result) => {
348
+ const run = (left) => Deferred.toPromise(data(signal)).then((result) => {
338
349
  if (Result.isOk(result)) return result;
339
350
  if (left <= 1) return result;
340
351
  if (shouldRetry !== void 0 && !shouldRetry(result.error)) {
341
352
  return result;
342
353
  }
354
+ if (signal?.aborted) return result;
343
355
  const ms = getDelay(attempts - left + 1);
344
- return (ms > 0 ? new Promise((r) => setTimeout(r, ms)) : Promise.resolve()).then(() => run(left - 1));
356
+ return cancellableWait(ms, signal).then(() => {
357
+ if (signal?.aborted) return result;
358
+ return run(left - 1);
359
+ });
345
360
  });
346
361
  return run(attempts);
347
362
  });
348
- TaskResult2.pollUntil = (options) => (task) => Task.from(() => {
363
+ TaskResult2.pollUntil = (options) => (task) => Task.from((signal) => {
349
364
  const { when: predicate, delay } = options;
350
365
  const getDelay = (attempt) => delay === void 0 ? 0 : typeof delay === "function" ? delay(attempt) : delay;
351
- const run = (attempt) => Deferred.toPromise(task()).then((result) => {
366
+ const run = (attempt) => Deferred.toPromise(task(signal)).then((result) => {
352
367
  if (Result.isErr(result)) return result;
353
368
  if (predicate(result.value)) return result;
369
+ if (signal?.aborted) return result;
354
370
  const ms = getDelay(attempt);
355
- return (ms > 0 ? new Promise((r) => setTimeout(r, ms)) : Promise.resolve()).then(() => run(attempt + 1));
371
+ return cancellableWait(ms, signal).then(() => {
372
+ if (signal?.aborted) return result;
373
+ return run(attempt + 1);
374
+ });
356
375
  });
357
376
  return run(1);
358
377
  });
359
- TaskResult2.timeout = (ms, onTimeout) => (data) => Task.from(() => {
378
+ TaskResult2.timeout = (ms, onTimeout) => (data) => Task.from((outerSignal) => {
379
+ const controller = new AbortController();
380
+ const onOuterAbort = () => controller.abort();
381
+ outerSignal?.addEventListener("abort", onOuterAbort, { once: true });
360
382
  let timerId;
361
383
  return Promise.race([
362
- Deferred.toPromise(data()).then((result) => {
384
+ Deferred.toPromise(data(controller.signal)).then((result) => {
363
385
  clearTimeout(timerId);
386
+ outerSignal?.removeEventListener("abort", onOuterAbort);
364
387
  return result;
365
388
  }),
366
389
  new Promise((resolve) => {
367
- timerId = setTimeout(() => resolve(Result.err(onTimeout())), ms);
390
+ timerId = setTimeout(() => {
391
+ controller.abort();
392
+ outerSignal?.removeEventListener("abort", onOuterAbort);
393
+ resolve(Result.err(onTimeout()));
394
+ }, ms);
368
395
  })
369
396
  ]);
370
397
  });
398
+ TaskResult2.abortable = (factory, onError) => {
399
+ const controller = new AbortController();
400
+ const task = (outerSignal) => {
401
+ if (outerSignal) {
402
+ if (outerSignal.aborted) {
403
+ controller.abort(outerSignal.reason);
404
+ } else {
405
+ outerSignal.addEventListener("abort", () => controller.abort(outerSignal.reason), { once: true });
406
+ }
407
+ }
408
+ return Deferred.fromPromise(
409
+ factory(controller.signal).then(Result.ok).catch((e) => Result.err(onError(e)))
410
+ );
411
+ };
412
+ return { task, abort: () => controller.abort() };
413
+ };
371
414
  })(TaskResult || (TaskResult = {}));
372
415
 
373
416
  // src/Core/Validation.ts
@@ -3,7 +3,7 @@ import {
3
3
  Maybe,
4
4
  Result,
5
5
  Task
6
- } from "./chunk-EAR4TIGH.mjs";
6
+ } from "./chunk-RUDOUVQR.mjs";
7
7
  import {
8
8
  isNonEmptyList
9
9
  } from "./chunk-DBIC62UV.mjs";
@@ -69,77 +69,99 @@ var Result;
69
69
  })(Result || (Result = {}));
70
70
 
71
71
  // src/Core/Task.ts
72
- var toPromise = (task) => Deferred.toPromise(task());
72
+ var toPromise = (task, signal) => Deferred.toPromise(task(signal));
73
73
  var Task;
74
74
  ((Task2) => {
75
75
  Task2.resolve = (value) => () => Deferred.fromPromise(Promise.resolve(value));
76
- Task2.from = (f) => () => Deferred.fromPromise(f());
77
- Task2.map = (f) => (data) => (0, Task2.from)(() => toPromise(data).then(f));
78
- Task2.chain = (f) => (data) => (0, Task2.from)(() => toPromise(data).then((a) => toPromise(f(a))));
76
+ Task2.from = (f) => (signal) => Deferred.fromPromise(f(signal));
77
+ Task2.map = (f) => (data) => (0, Task2.from)((signal) => toPromise(data, signal).then(f));
78
+ Task2.chain = (f) => (data) => (0, Task2.from)((signal) => toPromise(data, signal).then((a) => toPromise(f(a), signal)));
79
79
  Task2.ap = (arg) => (data) => (0, Task2.from)(
80
- () => Promise.all([
81
- toPromise(data),
82
- toPromise(arg)
80
+ (signal) => Promise.all([
81
+ toPromise(data, signal),
82
+ toPromise(arg, signal)
83
83
  ]).then(([f, a]) => f(a))
84
84
  );
85
85
  Task2.tap = (f) => (data) => (0, Task2.from)(
86
- () => toPromise(data).then((a) => {
86
+ (signal) => toPromise(data, signal).then((a) => {
87
87
  f(a);
88
88
  return a;
89
89
  })
90
90
  );
91
91
  Task2.all = (tasks) => (0, Task2.from)(
92
- () => Promise.all(tasks.map((t) => toPromise(t)))
92
+ (signal) => Promise.all(tasks.map((t) => toPromise(t, signal)))
93
93
  );
94
94
  Task2.delay = (ms) => (data) => (0, Task2.from)(
95
- () => new Promise(
95
+ (signal) => new Promise(
96
96
  (resolve2) => setTimeout(
97
- () => toPromise(data).then(resolve2),
97
+ () => toPromise(data, signal).then(resolve2),
98
98
  ms
99
99
  )
100
100
  )
101
101
  );
102
- Task2.repeat = (options) => (task) => (0, Task2.from)(() => {
102
+ Task2.repeat = (options) => (task) => (0, Task2.from)((signal) => {
103
103
  const { times, delay: ms } = options;
104
104
  if (times <= 0) return Promise.resolve([]);
105
105
  const results = [];
106
106
  const wait = () => ms !== void 0 && ms > 0 ? new Promise((r) => setTimeout(r, ms)) : Promise.resolve();
107
- const run = (left) => toPromise(task).then((a) => {
107
+ const run = (left) => toPromise(task, signal).then((a) => {
108
108
  results.push(a);
109
109
  if (left <= 1) return results;
110
110
  return wait().then(() => run(left - 1));
111
111
  });
112
112
  return run(times);
113
113
  });
114
- Task2.repeatUntil = (options) => (task) => (0, Task2.from)(() => {
114
+ Task2.repeatUntil = (options) => (task) => (0, Task2.from)((signal) => {
115
115
  const { when: predicate, delay: ms } = options;
116
116
  const wait = () => ms !== void 0 && ms > 0 ? new Promise((r) => setTimeout(r, ms)) : Promise.resolve();
117
- const run = () => toPromise(task).then((a) => {
117
+ const run = () => toPromise(task, signal).then((a) => {
118
118
  if (predicate(a)) return a;
119
119
  return wait().then(run);
120
120
  });
121
121
  return run();
122
122
  });
123
- Task2.race = (tasks) => (0, Task2.from)(() => Promise.race(tasks.map(toPromise)));
124
- Task2.sequential = (tasks) => (0, Task2.from)(async () => {
123
+ Task2.race = (tasks) => (0, Task2.from)((signal) => Promise.race(tasks.map((t) => toPromise(t, signal))));
124
+ Task2.sequential = (tasks) => (0, Task2.from)(async (signal) => {
125
125
  const results = [];
126
126
  for (const task of tasks) {
127
- results.push(await toPromise(task));
127
+ results.push(await toPromise(task, signal));
128
128
  }
129
129
  return results;
130
130
  });
131
- Task2.timeout = (ms, onTimeout) => (task) => (0, Task2.from)(() => {
131
+ Task2.timeout = (ms, onTimeout) => (task) => (0, Task2.from)((outerSignal) => {
132
+ const controller = new AbortController();
133
+ const onOuterAbort = () => controller.abort();
134
+ outerSignal?.addEventListener("abort", onOuterAbort, { once: true });
132
135
  let timerId;
133
136
  return Promise.race([
134
- toPromise(task).then((a) => {
137
+ toPromise(task, controller.signal).then((a) => {
135
138
  clearTimeout(timerId);
139
+ outerSignal?.removeEventListener("abort", onOuterAbort);
136
140
  return Result.ok(a);
137
141
  }),
138
142
  new Promise((resolve2) => {
139
- timerId = setTimeout(() => resolve2(Result.err(onTimeout())), ms);
143
+ timerId = setTimeout(() => {
144
+ controller.abort();
145
+ outerSignal?.removeEventListener("abort", onOuterAbort);
146
+ resolve2(Result.err(onTimeout()));
147
+ }, ms);
140
148
  })
141
149
  ]);
142
150
  });
151
+ Task2.abortable = (factory) => {
152
+ const controller = new AbortController();
153
+ const task = (outerSignal) => {
154
+ if (outerSignal) {
155
+ if (outerSignal.aborted) {
156
+ controller.abort(outerSignal.reason);
157
+ } else {
158
+ outerSignal.addEventListener("abort", () => controller.abort(outerSignal.reason), { once: true });
159
+ }
160
+ }
161
+ return Deferred.fromPromise(factory(controller.signal));
162
+ };
163
+ return { task, abort: () => controller.abort() };
164
+ };
143
165
  })(Task || (Task = {}));
144
166
 
145
167
  export {
package/dist/core.d.mts CHANGED
@@ -1,5 +1,5 @@
1
- import { M as Maybe, W as WithValue, a as WithLog, R as Result, b as WithKind, c as WithError, T as Task, d as WithErrors, e as WithFirst, f as WithSecond } from './Task-DBW4nOZR.mjs';
2
- export { D as Deferred, E as Err, N as None, O as Ok, S as Some } from './Task-DBW4nOZR.mjs';
1
+ import { M as Maybe, W as WithValue, a as WithLog, R as Result, b as WithKind, c as WithError, T as Task, d as WithErrors, e as WithFirst, f as WithSecond } from './Task-JOnNAaPq.mjs';
2
+ export { D as Deferred, E as Err, N as None, O as Ok, S as Some } from './Task-JOnNAaPq.mjs';
3
3
  import { N as NonEmptyList } from './NonEmptyList-BlGFjor5.mjs';
4
4
 
5
5
  /** Keys of T for which undefined is assignable (i.e. optional fields). */
@@ -1053,7 +1053,7 @@ declare namespace RemoteData {
1053
1053
  * ```ts
1054
1054
  * const fetchUser = (id: string): TaskResult<Error, User> =>
1055
1055
  * TaskResult.tryCatch(
1056
- * () => fetch(`/users/${id}`).then(r => r.json()),
1056
+ * (signal) => fetch(`/users/${id}`, { signal }).then(r => r.json()),
1057
1057
  * (e) => new Error(`Failed to fetch user: ${e}`)
1058
1058
  * );
1059
1059
  * ```
@@ -1071,17 +1071,18 @@ declare namespace TaskResult {
1071
1071
  /**
1072
1072
  * Creates a TaskResult from a function that may throw.
1073
1073
  * Catches any errors and transforms them using the onError function.
1074
+ * The factory optionally receives an `AbortSignal` forwarded from the call site.
1074
1075
  *
1075
1076
  * @example
1076
1077
  * ```ts
1077
- * const parseJson = (s: string): TaskResult<string, unknown> =>
1078
+ * const fetchUser = (id: string): TaskResult<string, User> =>
1078
1079
  * TaskResult.tryCatch(
1079
- * async () => JSON.parse(s),
1080
- * (e) => `Parse error: ${e}`
1080
+ * (signal) => fetch(`/users/${id}`, { signal }).then(r => r.json()),
1081
+ * String
1081
1082
  * );
1082
1083
  * ```
1083
1084
  */
1084
- const tryCatch: <E, A>(f: () => Promise<A>, onError: (e: unknown) => E) => TaskResult<E, A>;
1085
+ const tryCatch: <E, A>(f: (signal?: AbortSignal) => Promise<A>, onError: (e: unknown) => E) => TaskResult<E, A>;
1085
1086
  /**
1086
1087
  * Transforms the success value inside a TaskResult.
1087
1088
  */
@@ -1123,6 +1124,8 @@ declare namespace TaskResult {
1123
1124
  const tap: <E, A>(f: (a: A) => void) => (data: TaskResult<E, A>) => TaskResult<E, A>;
1124
1125
  /**
1125
1126
  * Re-runs a TaskResult on `Err` with configurable attempts, backoff, and retry condition.
1127
+ * An `AbortSignal` passed at the call site is forwarded to each attempt; the loop also
1128
+ * checks the signal before starting a new attempt so cancellation stops the loop promptly.
1126
1129
  *
1127
1130
  * @param options.attempts - Total number of attempts (1 = no retry, 3 = up to 3 tries)
1128
1131
  * @param options.backoff - Fixed delay in ms, or a function `(attempt) => ms` for computed delay
@@ -1151,6 +1154,8 @@ declare namespace TaskResult {
1151
1154
  /**
1152
1155
  * Polls a TaskResult repeatedly until the success value satisfies a predicate.
1153
1156
  * Stops immediately and returns `Err` if the task fails.
1157
+ * An `AbortSignal` passed at the call site is forwarded to each attempt; the loop
1158
+ * also checks the signal before starting a new poll so cancellation stops promptly.
1154
1159
  *
1155
1160
  * `delay` accepts a fixed number of milliseconds or a function `(attempt) => ms`
1156
1161
  * for a computed delay — useful for starting fast and slowing down over time.
@@ -1158,7 +1163,10 @@ declare namespace TaskResult {
1158
1163
  * @example
1159
1164
  * ```ts
1160
1165
  * const checkJob = (id: string): TaskResult<string, { status: "pending" | "done" }> =>
1161
- * TaskResult.tryCatch(() => fetch(`/jobs/${id}`).then(r => r.json()), String);
1166
+ * TaskResult.tryCatch(
1167
+ * (signal) => fetch(`/jobs/${id}`, { signal }).then(r => r.json()),
1168
+ * String
1169
+ * );
1162
1170
  *
1163
1171
  * // Fixed delay: poll every 2s
1164
1172
  * pipe(
@@ -1179,7 +1187,8 @@ declare namespace TaskResult {
1179
1187
  }) => <E>(task: TaskResult<E, A>) => TaskResult<E, A>;
1180
1188
  /**
1181
1189
  * Fails a TaskResult with a typed error if it does not resolve within the given time.
1182
- * Uses `Promise.race` the underlying operation keeps running after the timeout fires.
1190
+ * The inner task receives an `AbortSignal` that fires when the deadline passes —
1191
+ * operations like `fetch` that accept a signal are cancelled rather than left dangling.
1183
1192
  *
1184
1193
  * @example
1185
1194
  * ```ts
@@ -1190,6 +1199,31 @@ declare namespace TaskResult {
1190
1199
  * ```
1191
1200
  */
1192
1201
  const timeout: <E>(ms: number, onTimeout: () => E) => <A>(data: TaskResult<E, A>) => TaskResult<E, A>;
1202
+ /**
1203
+ * Creates a TaskResult paired with an `abort` handle. When `abort()` is called the
1204
+ * `AbortSignal` passed to the factory is fired, cancelling any in-flight operation.
1205
+ * The abort error is transformed by `onError` into a typed `Err`.
1206
+ *
1207
+ * If an outer signal is also present (passed at the call site), aborting it
1208
+ * propagates into the internal controller.
1209
+ *
1210
+ * @example
1211
+ * ```ts
1212
+ * const { task: req, abort } = TaskResult.abortable(
1213
+ * (signal) => fetch(`/users/${id}`, { signal }).then(r => r.json()),
1214
+ * String,
1215
+ * );
1216
+ *
1217
+ * const result = pipe(req, TaskResult.retry({ attempts: 3 }));
1218
+ *
1219
+ * onCancel(abort);
1220
+ * await result();
1221
+ * ```
1222
+ */
1223
+ const abortable: <E, A>(factory: (signal: AbortSignal) => Promise<A>, onError: (e: unknown) => E) => {
1224
+ task: TaskResult<E, A>;
1225
+ abort: () => void;
1226
+ };
1193
1227
  }
1194
1228
 
1195
1229
  /**