@pvorona/failable 0.7.0 → 0.9.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,6 +11,8 @@ A `Failable<T, E>` is either `Success<T>` or `Failure<E>`.
11
11
  - `success()` / `success(data)` / `failure()` / `failure(error)` create results
12
12
  - `failable(...)` captures thrown or rejected boundaries
13
13
  - `run(...)` composes multiple `Failable` steps
14
+ - `all(...)`, `allSettled(...)`, and `race(...)` combine multiple sources
15
+ - `result.map(...)` / `result.flatMap(...)` transform and chain success values
14
16
 
15
17
  ## Install
16
18
 
@@ -67,9 +69,12 @@ if (result.isFailure) {
67
69
  | Read the value or provide a fallback | `getOr(...)` / `getOrElse(...)` |
68
70
  | Recover to `Success<T>` | `or(...)` / `orElse(...)` |
69
71
  | Map both branches to one output | `match(onSuccess, onFailure)` |
70
- | Throw the stored failure unchanged | `getOrThrow()` / `throwIfError(result)` |
72
+ | Throw an `Error` from a failure | `getOrThrow(normalizeOption?)` / `throwIfFailure(result, normalizeOption?)` |
71
73
  | Capture a throwing or rejecting boundary | `failable(...)` |
72
74
  | Compose multiple `Failable` steps | `run(...)` |
75
+ | Combine multiple `Failable` sources | `all(...)`, `allSettled(...)`, `race(...)` |
76
+ | Transform a successful value only | `map(...)` |
77
+ | Chain another `Failable` step | `flatMap(...)` |
73
78
  | Cross a structured-clone boundary | `toFailableLike(...)` + `failable(...)` |
74
79
  | Validate `unknown` input | `isFailable(...)`, `isSuccess(...)`, `isFailure(...)`, `isFailableLike(...)` |
75
80
 
@@ -79,14 +84,36 @@ Start with ordinary branching on `result.isFailure` or `result.isSuccess`. When
79
84
  you want something shorter, use the helper that matches the job:
80
85
 
81
86
  - `result.getOr(fallback)`: return the success value or an eager fallback
82
- - `result.getOrElse(() => fallback)`: same, but lazily
87
+ - `result.getOrElse(() => fallback)`: lazy fallback
88
+ - `result.getOrElse((error) => fallback)`: lazy fallback derived from the failure
83
89
  - `result.or(fallback)`: recover to `Success<T>` with an eager fallback
84
- - `result.orElse(() => fallback)`: recover to `Success<T>` lazily
90
+ - `result.orElse(() => fallback)`: lazy recovery to `Success<T>`
91
+ - `result.orElse((error) => fallback)`: lazy recovery to `Success<T>` derived from the failure
85
92
  - `result.match(onSuccess, onFailure)`: map both branches to one output
86
- - `result.getOrThrow()`: return the success value or throw `result.error`
87
- - `throwIfError(result)`: throw `result.error` and narrow the same variable
93
+ - `result.getOrThrow(normalizeOption?)`: return the success value or throw an `Error` derived from the failure
94
+ - `throwIfFailure(result, normalizeOption?)`: throw an `Error` derived from the failure and narrow the same variable
88
95
 
89
- Use the lazy forms when the fallback is expensive or has side effects.
96
+ Both throw helpers preserve existing `Error` instances unchanged by default.
97
+ Other failure values are normalized with the built-in rules: arrays become
98
+ `AggregateError`; plain objects become `Error` with `cause`; primitives and
99
+ `undefined` become `Error(String(value), { cause: value })`.
100
+
101
+ Pass `NormalizedErrors` or a custom `normalizeError(...)` when you need a
102
+ specific `Error` shape at the throw boundary. Normalize earlier with
103
+ `failable(...)` only when you need that normalized `Error` inside the
104
+ `Failure` channel before anything throws. If built-in message derivation
105
+ itself fails, normalization still returns an `Error` with message
106
+ `Unstringifiable error value` and `cause` set to the original raw value.
107
+
108
+ Use the lazy forms when the fallback is expensive or has side effects. Failure
109
+ callbacks receive the stored error, so `() => ...` can ignore it and
110
+ `(error) => ...` can use it:
111
+
112
+ ```ts
113
+ const port = result.getOrElse((error) => {
114
+ return error.code === 'missing' ? 3000 : 8080;
115
+ });
116
+ ```
90
117
 
91
118
  Using `readPort` from above:
92
119
 
@@ -100,18 +127,83 @@ const label = result.match(
100
127
  );
101
128
  ```
102
129
 
103
- `throwIfError` narrows the result to `Success` in place, so
130
+ `throwIfFailure` narrows the result to `Success` in place, so
104
131
  subsequent code can access `.data` without branching:
105
132
 
106
133
  ```ts
107
- import { throwIfError } from '@pvorona/failable';
134
+ import { throwIfFailure } from '@pvorona/failable';
108
135
 
109
136
  const result = readPort(process.env.PORT);
110
137
 
111
- throwIfError(result);
138
+ throwIfFailure(result);
112
139
  console.log(result.data * 2);
113
140
  ```
114
141
 
142
+ When you want a specific `Error` shape only at the throw site, pass the
143
+ normalize option there:
144
+
145
+ ```ts
146
+ import { NormalizedErrors } from '@pvorona/failable';
147
+
148
+ const port = readPort(process.env.PORT).getOrThrow(NormalizedErrors);
149
+ ```
150
+
151
+ ## Transform And Chain With `map(...)` And `flatMap(...)`
152
+
153
+ Use `result.map(fn)` when you only need to change the success value. The callback
154
+ runs on `Success` only; on `Failure`, the same failure is returned unchanged.
155
+
156
+ Use `result.flatMap(fn)` when the next step can fail again. The callback must
157
+ return another `Failable`. On `Success`, that result becomes the outcome; on
158
+ `Failure`, `flatMap` short-circuits and keeps the original error.
159
+
160
+ Building on `readPort` from [Basic Usage](#basic-usage):
161
+
162
+ ```ts
163
+ import { failure, success, type Failable } from '@pvorona/failable';
164
+
165
+ type ReadPortError =
166
+ | { code: 'missing' }
167
+ | { code: 'invalid'; raw: string };
168
+
169
+ type ApplicationPortError =
170
+ | ReadPortError
171
+ | { code: 'not_application_port'; port: number };
172
+
173
+ function readPort(raw: string | undefined): Failable<number, ReadPortError> {
174
+ if (raw === undefined) return failure({ code: 'missing' });
175
+
176
+ const port = Number(raw);
177
+ if (!Number.isInteger(port) || port <= 0) {
178
+ return failure({ code: 'invalid', raw });
179
+ }
180
+
181
+ return success(port);
182
+ }
183
+
184
+ function ensureApplicationPort(
185
+ port: number,
186
+ ): Failable<number, ApplicationPortError> {
187
+ if (port < 3000 || port > 3999) {
188
+ return failure({ code: 'not_application_port', port });
189
+ }
190
+
191
+ return success(port);
192
+ }
193
+
194
+ const appPortResult = readPort(process.env.PORT).flatMap((port) =>
195
+ ensureApplicationPort(port),
196
+ );
197
+
198
+ const labelResult = appPortResult.map(
199
+ (port) => `Application listening on ${port}`,
200
+ );
201
+ ```
202
+
203
+ When you pass object literals directly into `success(...)` or `failure(...)`,
204
+ TypeScript often keeps their types as narrow as possible (literal fields where
205
+ that makes sense), which helps `switch` on `error.code` and similar patterns.
206
+
115
207
  ## Capture Thrown Or Rejected Failures With `failable(...)`
116
208
 
117
209
  Use `failable(...)` at a boundary you do not control. It turns a thrown or
@@ -134,7 +226,8 @@ if (configResult.isFailure) {
134
226
  ```
135
227
 
136
228
  `NormalizedErrors` is the built-in shortcut when you want `.error` to be an
137
- `Error`.
229
+ `Error`, including when the thrown or rejected non-`Error` value cannot be
230
+ stringified safely.
138
231
 
139
232
  Pass a promise directly when you want rejection capture:
140
233
 
@@ -155,20 +248,34 @@ const config = fileResult.getOr('{}');
155
248
  - preserve an existing `Failable`
156
249
  - rehydrate a `FailableLike`
157
250
  - capture sync throws from a callback
158
- - capture promise rejections from a promise
251
+ - capture promise rejections from a promise passed directly
159
252
  - normalize failures with `NormalizedErrors` or a custom `normalizeError(...)`
160
253
 
161
254
  By default, the thrown or rejected value becomes `.error` unchanged.
162
255
 
163
- Pass the promise itself when you want rejection capture.
164
- `failable(async () => value)` is misuse and returns a `Failure<Error>` telling
165
- you to pass the promise directly instead.
256
+ Pass the promise itself when you want rejection capture. In TypeScript,
257
+ obviously promise-returning callbacks like `async () => ...` and
258
+ `() => Promise.resolve(...)` are rejected. JS callers, plus `any`/`unknown`-typed
259
+ callbacks, receive a `Failure<Error>` telling them to pass the promise directly
260
+ instead.
166
261
 
167
262
  ## Compose Existing `Failable` Steps With `run(...)`
168
263
 
169
264
  Use `run(...)` when each step already returns `Failable` and you want to write
170
- the success path once. If any yielded step fails, `run(...)` returns that same
171
- failure unchanged.
265
+ the success path once. If any yielded step fails, that failure becomes the
266
+ default unwind result. Cleanup still runs first, and an explicit `return`
267
+ reached in `finally` overrides it. Yielded cleanup `Failure` values keep the
268
+ current unwind result unless a later cleanup `return` overrides it.
269
+
270
+ Inside a `run(...)` builder, there are two valid delegation forms:
271
+
272
+ - `yield* result` when `result` is already a hydrated `Failable`
273
+ - `yield* await promisedResult` in async builders when you have a
274
+ `Promise<Failable<...>>`
275
+
276
+ Hydrated `Failable` values expose sync and async iterators so `run(...)` can
277
+ intercept `yield* result` in both sync and async builders. Outside `run(...)`,
278
+ treat them as result objects rather than as a general-purpose collection API.
172
279
 
173
280
  Without `run(...)`, composing steps means checking each result before
174
281
  continuing:
@@ -233,16 +340,20 @@ function loadConfig(
233
340
  }
234
341
  ```
235
342
 
236
- When a helper already returns a hydrated sync `Failable`, yield it directly with
237
- `yield* helper()`. Keep `yield* get(...)` for sources that are promises or
238
- thenables.
343
+ When a helper already returns a hydrated `Failable`, yield it directly with
344
+ `yield* helper()`. For promised sources in async builders, await them first and
345
+ then yield the hydrated result with `yield* await promisedHelper()`.
346
+
347
+ `run(...)` does not inject helper arguments. Import the top-level combinators
348
+ you need and use them directly inside the builder.
239
349
 
240
350
  For async flows, switch to `run(async function* ...)`. Sync hydrated helpers
241
- still work with direct `yield* helper()`, while `get(...)` handles promised
242
- sources and keeps their full `Success` / `Failure` union inference:
351
+ still work with direct `yield* helper()`, and promised sources compose with
352
+ `yield* await ...`:
243
353
 
244
354
  ```ts
245
355
  import {
356
+ all,
246
357
  failable,
247
358
  failure,
248
359
  run,
@@ -261,17 +372,17 @@ type Profile = { id: string; pictureUrl: string };
261
372
  async function readJson<T>(url: string) {
262
373
  const responseResult = await failable(fetch(url));
263
374
  if (responseResult.isFailure) {
264
- return failure({ code: 'network_error', cause: responseResult.error } as const);
375
+ return failure({ code: 'network_error', cause: responseResult.error });
265
376
  }
266
377
 
267
378
  const response = responseResult.data;
268
379
  if (!response.ok) {
269
- return failure({ code: 'http_error', status: response.status } as const);
380
+ return failure({ code: 'http_error', status: response.status });
270
381
  }
271
382
 
272
383
  const jsonResult = await failable(response.json());
273
384
  if (jsonResult.isFailure) {
274
- return failure({ code: 'json_parse_error', cause: jsonResult.error } as const);
385
+ return failure({ code: 'json_parse_error', cause: jsonResult.error });
275
386
  }
276
387
 
277
388
  return success(jsonResult.data as T);
@@ -288,22 +399,94 @@ async function getUserProfile(userId: string) {
288
399
  async function loadUserPage(
289
400
  userId: string,
290
401
  ): Promise<Failable<{ user: User; profile: Profile }, ApiError>> {
291
- return await run(async function* ({ get }) {
292
- const user = yield* get(getUser(userId));
293
- const profile = yield* get(getUserProfile(userId));
402
+ return await run(async function* () {
403
+ const [user, profile] = yield* await all(
404
+ getUser(userId),
405
+ getUserProfile(userId)
406
+ );
294
407
 
295
408
  return success({ user, profile });
296
409
  });
297
410
  }
298
411
  ```
299
412
 
300
- - if a yielded step fails, `run(...)` returns that original failure unchanged
413
+ - if a yielded step fails, that failure becomes the default unwind result
414
+ - cleanup still runs, and the last explicit `return` reached in `finally` wins
415
+ - yielded cleanup `Failure` values keep the current unwind result unless a
416
+ later cleanup `return` overrides it
301
417
  - sync hydrated `Failable` helpers can use direct `yield* helper()` in both sync
302
418
  and async builders
303
- - promised sources still use `yield* get(...)`; do not write `await get(...)`
419
+ - promised sources in async builders use `yield* await promisedHelper()`
420
+ - in async builders, use `yield* await all(...)` to run multiple sources in
421
+ parallel and get a success tuple or the first failure
422
+ - use `yield* all(...)` in sync builders when every source is already a hydrated
423
+ `Failable`
424
+ - use `await allSettled(...)` to inspect the settled tuple of sources that
425
+ resolve to `Failable`; source promise rejections still reject unchanged
426
+ - use `yield* race(...)` when every raced source is already a hydrated
427
+ `Failable`
428
+ - use `yield* await race(...)` when any raced source is promised
429
+ - direct promised sources still follow normal async `await` / `try` /
430
+ `finally` semantics rather than a helper-managed rejection path
304
431
  - `run(...)` does not capture thrown values or rejected promises into `Failure`;
305
432
  wrap throwing boundaries with `failable(...)` before they enter `run(...)`
306
433
 
434
+ ## Parallel Combinators
435
+
436
+ Import `all(...)`, `allSettled(...)`, and `race(...)` from the package root when
437
+ you want to combine multiple sources outside `run(...)` or inside async
438
+ builders.
439
+
440
+ ```ts
441
+ import {
442
+ all,
443
+ allSettled,
444
+ failure,
445
+ race,
446
+ success,
447
+ } from '@pvorona/failable';
448
+
449
+ const syncTuple = all(success(1), success('two'));
450
+ const mixedTuple = await all(
451
+ success(1),
452
+ Promise.resolve(success('two'))
453
+ );
454
+
455
+ const settled = await allSettled(
456
+ Promise.resolve(success(1)),
457
+ Promise.resolve(failure('missing-profile'))
458
+ );
459
+
460
+ const syncWinner = race(
461
+ success('cached'),
462
+ success('stale')
463
+ );
464
+
465
+ const mixedWinner = await race(
466
+ success('cached'),
467
+ Promise.resolve(success('network'))
468
+ );
469
+ ```
470
+
471
+ Key semantics:
472
+
473
+ - `all(...)` returns the first failure in input order
474
+ - `allSettled(...)` returns a plain settled tuple rather than a `Success` wrapper
475
+ - `allSettled(...)` preserves `Failure` values in the returned settled tuple
476
+ - `allSettled(...)` only settles sources that resolve to `Failable` values
477
+ - promised source rejections in `allSettled(...)` still reject the combinator
478
+ - wrap rejecting boundaries with `failable(...)` first if you want a rejection
479
+ converted into `Failure`
480
+ - bare `Promise.reject(...)` inputs are rejected at type level as a best-effort
481
+ guardrail; TypeScript still cannot model arbitrary promise rejection channels
482
+ precisely
483
+ - `race(...)` accepts sync or promised `Failable` sources
484
+ - `race(...)` returns sync `Failable` when every source is sync, otherwise
485
+ `Promise<Failable>`
486
+ - when `race(...)` mixes already-settled sync and promised sources, winner
487
+ order follows normal `Promise.race(...)` input ordering
488
+ - `race()` with zero sources rejects with a clear error instead of hanging
489
+
307
490
  ## Transport And Runtime Validation
308
491
 
309
492
  `Failable` values are hydrated objects with methods. Keep them inside your
@@ -318,7 +501,7 @@ import {
318
501
  toFailableLike,
319
502
  } from '@pvorona/failable';
320
503
 
321
- const result = failure({ code: 'missing' as const });
504
+ const result = failure({ code: 'missing' });
322
505
 
323
506
  const wire = toFailableLike(result);
324
507
  const hydrated = failable(wire);
@@ -348,14 +531,17 @@ if (isFailable(candidate) && candidate.isFailure) {
348
531
  - `type Success<T>` / `type Failure<E>`: hydrated result variants
349
532
  - `type FailableLike<T, E>`: structured-clone-friendly wire shape
350
533
  - `success()` / `success(data)` / `failure()` / `failure(error)`: create hydrated results
351
- - `throwIfError(result)` / `result.getOrThrow()`: throw the stored failure
352
- unchanged
534
+ - `throwIfFailure(result, normalizeOption?)` / `result.getOrThrow(normalizeOption?)`:
535
+ throw an `Error`, preserving existing `Error` instances unchanged by default
353
536
  - `failable(...)`: preserve, rehydrate, capture, or normalize failures at
354
537
  a boundary
355
538
  - `run(...)`: compose `Failable` steps without nested branching
539
+ - `result.map(...)`: transform success data; failures pass through unchanged
540
+ - `result.flatMap(...)`: chain another `Failable`; failures short-circuit
356
541
  - `toFailableLike(...)`: convert a hydrated result into a wire shape
357
542
  - `isFailableLike(...)`: validate a wire shape
358
543
  - `isFailable(...)`, `isSuccess(...)`, `isFailure(...)`: validate hydrated
359
544
  results
360
- - `NormalizedErrors`: built-in `Error` normalization for `failable(...)`
545
+ - `NormalizedErrors`: built-in `Error` normalization for `failable(...)` and
546
+ throw-boundary helpers
361
547
  - `FailableStatus`: runtime success/failure status values
package/dist/index.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- export { FailableStatus, NormalizedErrors, failable, failure, isFailable, isFailableLike, isFailure, isSuccess, run, success, throwIfError, toFailableLike, } from './lib/failable.js';
1
+ export { all, allSettled, FailableStatus, NormalizedErrors, failable, failure, isFailable, isFailableLike, isFailure, isSuccess, race, run, success, throwIfFailure, toFailableLike, } from './lib/failable.js';
2
2
  export type { FailableNormalizeErrorOptions, Failable, FailableLike, FailableLikeFailure, FailableLikeSuccess, Failure, Success, } from './lib/failable.js';
3
3
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,cAAc,EACd,gBAAgB,EAChB,QAAQ,EACR,OAAO,EACP,UAAU,EACV,cAAc,EACd,SAAS,EACT,SAAS,EACT,GAAG,EACH,OAAO,EACP,YAAY,EACZ,cAAc,GACf,MAAM,mBAAmB,CAAC;AAE3B,YAAY,EACV,6BAA6B,EAC7B,QAAQ,EACR,YAAY,EACZ,mBAAmB,EACnB,mBAAmB,EACnB,OAAO,EACP,OAAO,GACR,MAAM,mBAAmB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,GAAG,EACH,UAAU,EACV,cAAc,EACd,gBAAgB,EAChB,QAAQ,EACR,OAAO,EACP,UAAU,EACV,cAAc,EACd,SAAS,EACT,SAAS,EACT,IAAI,EACJ,GAAG,EACH,OAAO,EACP,cAAc,EACd,cAAc,GACf,MAAM,mBAAmB,CAAC;AAE3B,YAAY,EACV,6BAA6B,EAC7B,QAAQ,EACR,YAAY,EACZ,mBAAmB,EACnB,mBAAmB,EACnB,OAAO,EACP,OAAO,GACR,MAAM,mBAAmB,CAAC"}