@pvorona/failable 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,15 @@
2
2
 
3
3
  Typed success/failure results for expected failures in TypeScript.
4
4
 
5
- `Failable<T, E>` is a discriminated union of `Success<T>` and `Failure<E>`. In normal application code, return `success(...)` / `failure(...)`, then branch with `result.isSuccess` / `result.isError` on that local result. Reserve `isSuccess(...)` / `isFailure(...)` for validating hydrated values that arrive as `unknown`, and use `isFailableLike(...)` for plain transport shapes.
5
+ Use `@pvorona/failable` when failure is part of normal control flow: invalid
6
+ input, missing config, not found, or a dependency call that can fail. Return a
7
+ `Failable<T, E>` instead of throwing, then handle the result explicitly.
8
+
9
+ A `Failable<T, E>` is either `Success<T>` or `Failure<E>`.
10
+
11
+ - `success(...)` / `failure(...)` create results
12
+ - `failable(...)` captures thrown or rejected boundaries
13
+ - `run(...)` composes multiple `Failable` steps
6
14
 
7
15
  ## Install
8
16
 
@@ -10,82 +18,128 @@ Typed success/failure results for expected failures in TypeScript.
10
18
  npm i @pvorona/failable
11
19
  ```
12
20
 
13
- This package is ESM-only. Use `import` syntax; the published package declares `Node >=18`.
21
+ This package is ESM-only and requires Node 18+.
14
22
 
15
- ## Quick Start
23
+ ## Migration Note
16
24
 
17
- ```ts
18
- import type { Failable } from '@pvorona/failable';
19
- import { failure, success } from '@pvorona/failable';
25
+ If you are upgrading from the previous API name:
20
26
 
21
- function divide(a: number, b: number): Failable<number, string> {
22
- if (b === 0) return failure('Cannot divide by zero');
27
+ - `createFailable(x)` -> `failable(x)`
28
+ - `CreateFailableNormalizeErrorOptions` -> `FailableNormalizeErrorOptions`
29
+ - `result.isError` -> `result.isFailure`
30
+ - `.error` is unchanged
31
+ - `failure(...)`, `Failure<E>`, `throwIfError(...)`, and `getOrThrow()` are unchanged
23
32
 
24
- return success(a / b);
25
- }
33
+ ## Basic Usage
26
34
 
27
- const result = divide(10, 2);
35
+ Use `Failable` when callers need different behavior for different expected
36
+ failures. This is especially useful for business rules that schema validators do
37
+ not model for you:
28
38
 
29
- if (result.isError) {
30
- console.error(result.error);
31
- } else {
32
- console.log(result.data);
33
- }
34
- ```
39
+ ```ts
40
+ import { failure, success, type Failable } from '@pvorona/failable';
35
41
 
36
- ## Everyday Usage
42
+ type TransferRequest = {
43
+ fromAccountId: string;
44
+ toAccountId: string;
45
+ amountCents: number;
46
+ };
37
47
 
38
- ### Quick chooser
48
+ type TransferPlan = TransferRequest & {
49
+ feeCents: number;
50
+ };
39
51
 
40
- - Use `createFailable(() => ...)` when the boundary is synchronous code that might throw.
41
- - Use `await createFailable(promise)` when the boundary is async code that might reject.
42
- - Use `run(...)` when each step already returns `Failable` and you want to compose the happy path.
43
- - Use `throwIfError(result)` when you want to keep using the same `result` variable after narrowing.
44
- - Use `result.getOrThrow()` when you want the success value itself in expression or return position.
52
+ type TransferError =
53
+ | { code: 'same_account' }
54
+ | { code: 'amount_too_small'; minAmountCents: number }
55
+ | {
56
+ code: 'insufficient_funds';
57
+ balanceCents: number;
58
+ attemptedCents: number;
59
+ };
60
+
61
+ function planTransfer(
62
+ request: TransferRequest,
63
+ balanceCents: number,
64
+ ): Failable<TransferPlan, TransferError> {
65
+ if (request.fromAccountId === request.toAccountId) {
66
+ return failure({ code: 'same_account' });
67
+ }
45
68
 
46
- ### Return `success(...)` and `failure(...)`
69
+ if (request.amountCents < 100) {
70
+ return failure({ code: 'amount_too_small', minAmountCents: 100 });
71
+ }
47
72
 
48
- Use the explicit constructors when your function already knows which branch it should return:
73
+ if (balanceCents < request.amountCents) {
74
+ return failure({
75
+ code: 'insufficient_funds',
76
+ balanceCents,
77
+ attemptedCents: request.amountCents,
78
+ });
79
+ }
49
80
 
50
- ```ts
51
- import { failure, success } from '@pvorona/failable';
81
+ return success({ ...request, feeCents: 25 });
82
+ }
52
83
 
53
- const ok = success({ id: '1' });
54
- const err = failure({ code: 'bad_request' });
55
- ```
84
+ const result = planTransfer(
85
+ {
86
+ fromAccountId: 'checking',
87
+ toAccountId: 'savings',
88
+ amountCents: 10_000,
89
+ },
90
+ 4_500,
91
+ );
56
92
 
57
- ### Branch and unwrap with helpers
93
+ if (result.isFailure) {
94
+ switch (result.error.code) {
95
+ case 'same_account':
96
+ console.error('Choose a different destination account');
97
+ break;
98
+ case 'amount_too_small':
99
+ console.error(`Transfers start at ${result.error.minAmountCents} cents`);
100
+ break;
101
+ case 'insufficient_funds':
102
+ console.error(
103
+ `Balance ${result.error.balanceCents} is below ${result.error.attemptedCents}`
104
+ );
105
+ break;
106
+ }
107
+ } else {
108
+ console.log(result.data.feeCents);
109
+ }
110
+ ```
58
111
 
59
- Hydrated `Failable` values carry booleans and convenience methods. Start with ordinary branching on the result, then pick the helper that matches how you want to unwrap:
112
+ That is the default usage model for this package: return a typed result that
113
+ carries both the success value and the expected failure reason.
60
114
 
61
- ```ts
62
- import { failure, success } from '@pvorona/failable';
115
+ ## Choose The Right API
63
116
 
64
- const portResult = Math.random() > 0.5
65
- ? success(3000)
66
- : failure('Missing port');
117
+ | Need | Use |
118
+ | --- | --- |
119
+ | Return a successful or failed result from your own code | `success(...)` / `failure(...)` |
120
+ | Read the value or provide a fallback | `getOr(...)` / `getOrElse(...)` |
121
+ | Recover to `Success<T>` | `or(...)` / `orElse(...)` |
122
+ | Map both branches to one output | `match(onSuccess, onFailure)` |
123
+ | Throw the stored failure unchanged | `getOrThrow()` / `throwIfError(result)` |
124
+ | Capture a throwing or rejecting boundary | `failable(...)` |
125
+ | Compose multiple `Failable` steps | `run(...)` |
126
+ | Cross a structured-clone boundary | `toFailableLike(...)` + `failable(...)` |
127
+ | Validate `unknown` input | `isFailable(...)`, `isSuccess(...)`, `isFailure(...)`, `isFailableLike(...)` |
67
128
 
68
- if (portResult.isError) {
69
- console.error(portResult.error);
70
- } else {
71
- console.log(portResult.data);
72
- }
129
+ ## Unwrapping And Recovery
73
130
 
74
- const port = portResult.getOr(3000);
75
- const ensuredPort = portResult.or(3000);
76
- console.log(port, ensuredPort.data);
77
- ```
131
+ Start with ordinary branching on `result.isFailure` or `result.isSuccess`. When
132
+ you need a shorter form, use the helper that matches the job:
78
133
 
79
- - `result.isSuccess` / `result.isError`: branch on a hydrated result
80
- - `result.getOr(fallback)`: eagerly get the success value or a fallback
81
- - `result.getOrElse(() => fallback)`: lazily compute a fallback value only on failure
82
- - `result.or(fallback)`: eagerly recover to a `Success` result
83
- - `result.orElse(() => fallback)`: lazily recover to a `Success` result only on failure
84
- - `result.match(onSuccess, onFailure)`: map both branches to one output type
85
- - `throwIfError(result)`: throw the stored failure unchanged and keep using the same result on success
86
- - `result.getOrThrow()`: unwrap success as a value or throw the stored failure unchanged
134
+ - `result.getOr(fallback)`: return the success value or an eager fallback
135
+ - `result.getOrElse(() => fallback)`: same, but lazily
136
+ - `result.or(fallback)`: recover to `Success<T>` with an eager fallback
137
+ - `result.orElse(() => fallback)`: recover to `Success<T>` lazily
138
+ - `result.match(onSuccess, onFailure)`: map both branches to one output
139
+ - `result.getOrThrow()`: return the success value or throw `result.error`
140
+ - `throwIfError(result)`: throw `result.error` and narrow the same variable
87
141
 
88
- Use `throwIfError(result)` when you want to keep the same result variable and continue with narrowed access to `result.data`:
142
+ Use the lazy forms when the fallback is expensive or has side effects.
89
143
 
90
144
  ```ts
91
145
  import {
@@ -95,296 +149,305 @@ import {
95
149
  type Failable,
96
150
  } from '@pvorona/failable';
97
151
 
98
- const portResult: Failable<number, string> = Math.random() > 0.5
99
- ? success(3000)
100
- : failure('Missing port');
152
+ type QuoteError = {
153
+ code: 'pricing_unavailable';
154
+ };
101
155
 
102
- throwIfError(portResult);
103
- console.log(portResult.data);
104
- ```
156
+ const feeResult: Failable<number, QuoteError> =
157
+ Math.random() > 0.5
158
+ ? success(25)
159
+ : failure({ code: 'pricing_unavailable' });
105
160
 
106
- Use `getOrThrow()` when you want the success value itself in expression or return position:
161
+ const feeCents = feeResult.getOr(25);
162
+ const status = feeResult.match(
163
+ (value) => `Fee is ${value} cents`,
164
+ (error) => `Cannot quote fee: ${error.code}`
165
+ );
107
166
 
108
- ```ts
109
- const requiredPort = portResult.getOrThrow();
110
- console.log(requiredPort);
167
+ throwIfError(feeResult);
168
+ console.log(feeCents, status, feeResult.data);
111
169
  ```
112
170
 
113
- Use the lazy forms when the fallback is expensive or has side effects:
171
+ ## Capture Thrown Or Rejected Failures With `failable(...)`
172
+
173
+ Use `failable(...)` at boundaries that throw or reject, then normalize
174
+ that failure once at the boundary if needed:
175
+
176
+ Using `TransferPlan` from above:
114
177
 
115
178
  ```ts
116
- import { failure, success } from '@pvorona/failable';
179
+ import {
180
+ failable,
181
+ run,
182
+ success,
183
+ type Failable,
184
+ } from '@pvorona/failable';
117
185
 
118
- function readFallbackPort() {
119
- console.log('Reading fallback port from disk');
120
- return 3000;
121
- }
186
+ type SubmitTransferError = Error;
122
187
 
123
- const portResult = Math.random() > 0.5
124
- ? success(8080)
125
- : failure('Missing port');
188
+ async function postToLedger(
189
+ plan: TransferPlan,
190
+ ): Promise<Failable<{ transferId: string }, SubmitTransferError>> {
191
+ const request = (async () => {
192
+ if (plan.amountCents > 5_000) {
193
+ throw { code: 'ledger_unavailable' } as const;
194
+ }
126
195
 
127
- const eagerPort = portResult.getOr(readFallbackPort());
128
- const lazyPort = portResult.getOrElse(() => readFallbackPort());
129
- const ensuredPort = portResult.orElse(() => readFallbackPort());
196
+ return { transferId: 'tr_123' };
197
+ })();
198
+
199
+ return await failable(request, {
200
+ normalizeError(error) {
201
+ return new Error('Ledger unavailable', { cause: error });
202
+ },
203
+ });
204
+ }
130
205
 
131
- console.log(eagerPort, lazyPort, ensuredPort.data);
206
+ async function submitTransfer(
207
+ plan: TransferPlan,
208
+ ): Promise<Failable<{ transferId: string }, SubmitTransferError>> {
209
+ return await run(async function* ({ get }) {
210
+ const created = yield* get(postToLedger(plan));
211
+
212
+ return success(created);
213
+ });
214
+ }
132
215
  ```
133
216
 
134
- `readFallbackPort()` runs before `getOr(...)` because the fallback expression is evaluated eagerly. With `getOrElse(...)` and `orElse(...)`, the callback runs only if the result is a failure.
217
+ `postToLedger(...)` is the boundary adapter. It uses
218
+ `failable(..., { normalizeError })` to capture a raw throw/rejection once
219
+ and expose one stable `Error` shape to the rest of the app. If you only need
220
+ generic `Error` normalization, `NormalizedErrors` is the built-in shortcut.
221
+ Once that helper already returns `Failable`, `submitTransfer(...)` can use
222
+ `run(...)` to compose it like any other step.
135
223
 
136
- `match(...)` is often clearer than a fallback when both branches need real handling:
224
+ Pass a promise directly when you want rejection capture:
137
225
 
138
226
  ```ts
139
- import { failure, success } from '@pvorona/failable';
227
+ const responseResult = await failable(fetch(url));
228
+ ```
140
229
 
141
- const portResult = Math.random() > 0.5
142
- ? success(3000)
143
- : failure('Missing port');
230
+ `failable(...)` can:
144
231
 
145
- const status = portResult.match(
146
- (port) => `Listening on ${port}`,
147
- (error) => `Cannot start server: ${error}`
148
- );
149
- ```
232
+ - preserve an existing `Failable`
233
+ - rehydrate a `FailableLike`
234
+ - capture sync throws from a callback
235
+ - capture promise rejections from a promise
236
+ - normalize failures with `NormalizedErrors` or a custom `normalizeError(...)`
150
237
 
151
- ### `createFailable(...)` for throwy or rejecting code
238
+ By default, the thrown or rejected value becomes `.error` unchanged.
152
239
 
153
- `createFailable(...)` is the boundary helper for non-`Failable` code. Use it when you need to capture sync throws, promise rejections, or rehydrate an existing result shape. Unlike `run(...)`, it is not for `Failable`-to-`Failable` composition:
240
+ Pass the promise itself when you want rejection capture.
241
+ `failable(async () => value)` is misuse and returns a `Failure<Error>` telling
242
+ you to pass the promise directly instead.
154
243
 
155
- - `createFailable(failable)` returns the same tagged hydrated instance
156
- - `createFailable(failableLike)` rehydrates a strict wire shape into a real `Success` / `Failure`
157
- - `createFailable(() => value)` captures synchronous throws into `Failure`
158
- - `createFailable(promise)` captures async rejections into `Failure`
159
- - If a callback returns, or a promise resolves to, a `Failable` or `FailableLike`, `createFailable(...)` preserves that result instead of nesting it inside `Success`
244
+ ## Compose Existing `Failable` Steps With `run(...)`
160
245
 
161
- Plain lookalike objects are not treated as hydrated `Failable` instances. If you have plain `{ status, data }` or `{ status, error }` transport data, validate it with `isFailableLike(...)` or pass it to `createFailable(...)` to rehydrate before calling instance methods.
246
+ Use `run(...)` when each step already returns `Failable` and you want to write
247
+ the success path once:
162
248
 
163
- If a helper already returns `Failable`, branch on it directly or compose it with `run(...)` instead of wrapping it again with `createFailable(...)`.
249
+ Without `run(...)`, composition often becomes an error ladder:
164
250
 
165
251
  ```ts
166
- import {
167
- createFailable,
168
- failure,
169
- success,
170
- type Failable,
171
- } from '@pvorona/failable';
252
+ import { failure, success, type Failable } from '@pvorona/failable';
172
253
 
173
- type PortError = {
174
- readonly code: 'invalid_port';
254
+ type Account = {
255
+ id: string;
256
+ balanceCents: number;
175
257
  };
176
258
 
177
- function readPort(value: unknown): Failable<number, PortError> {
178
- if (typeof value !== 'number') return failure({ code: 'invalid_port' });
259
+ type TransferPlanningError =
260
+ | TransferError
261
+ | { code: 'source_account_not_found'; accountId: string }
262
+ | { code: 'destination_account_not_found'; accountId: string };
179
263
 
180
- return success(value);
181
- }
182
-
183
- const configResult = createFailable(() => JSON.parse(rawConfig));
264
+ function readSourceAccount(
265
+ accountId: string,
266
+ ): Failable<Account, TransferPlanningError> {
267
+ if (accountId !== 'checking') {
268
+ return failure({ code: 'source_account_not_found', accountId });
269
+ }
184
270
 
185
- if (configResult.isError) {
186
- console.error('Invalid JSON:', configResult.error);
187
- } else {
188
- const portResult = readPort(configResult.data.port);
271
+ return success({ id: 'checking', balanceCents: 5_000 });
272
+ }
189
273
 
190
- if (portResult.isError) {
191
- console.error(portResult.error.code);
192
- } else {
193
- console.log(portResult.data);
274
+ function readDestinationAccount(
275
+ accountId: string,
276
+ ): Failable<Account, TransferPlanningError> {
277
+ if (accountId !== 'savings') {
278
+ return failure({ code: 'destination_account_not_found', accountId });
194
279
  }
280
+
281
+ return success({ id: 'savings', balanceCents: 20_000 });
195
282
  }
196
- ```
197
283
 
198
- Pass promises directly when you want rejection capture:
284
+ function ensureDifferentAccounts(
285
+ source: Account,
286
+ destination: Account,
287
+ ): Failable<void, TransferPlanningError> {
288
+ if (source.id === destination.id) return failure({ code: 'same_account' });
199
289
 
200
- ```ts
201
- import { createFailable } from '@pvorona/failable';
290
+ return success(undefined);
291
+ }
202
292
 
203
- const responseResult = await createFailable(fetch(url));
204
- if (responseResult.isError) console.error(responseResult.error);
205
- ```
293
+ function ensureSufficientFunds(
294
+ source: Account,
295
+ amountCents: number,
296
+ ): Failable<Account, TransferPlanningError> {
297
+ if (source.balanceCents < amountCents) {
298
+ return failure({
299
+ code: 'insufficient_funds',
300
+ balanceCents: source.balanceCents,
301
+ attemptedCents: amountCents,
302
+ });
303
+ }
206
304
 
207
- `createFailable(() => promise)` is not the supported API. In TypeScript, obviously promise-returning callbacks such as `async () => ...` and `() => Promise.resolve(...)` are rejected. JS callers, plus `any`/`unknown`-typed callbacks, still rely on the runtime guard; those cases stay in the failure channel as an `Error` telling you to pass the promise directly instead. That guard error is preserved even when you pass a custom `normalizeError`.
305
+ return success(source);
306
+ }
208
307
 
209
- ### `run(...)` for `Failable` composition
308
+ function planTransfer(
309
+ request: TransferRequest,
310
+ ): Failable<TransferPlan, TransferPlanningError> {
311
+ const source = readSourceAccount(request.fromAccountId);
312
+ if (source.isFailure) return source;
210
313
 
211
- Use `run(...)` when each step already returns `Failable` and you want the happy path to read top-down. Inside both the sync and async builder forms, use `yield* get(result)` to unwrap success values. The mental model is simple: success values keep flowing forward, and the first yielded `Failure` short-circuits with that same `Failure` instance.
314
+ const destination = readDestinationAccount(request.toAccountId);
315
+ if (destination.isFailure) return destination;
212
316
 
213
- ```ts
214
- import { failure, run, success, type Failable } from '@pvorona/failable';
317
+ const differentAccounts = ensureDifferentAccounts(source.data, destination.data);
318
+ if (differentAccounts.isFailure) return differentAccounts;
215
319
 
216
- function divide(a: number, b: number): Failable<number, string> {
217
- if (b === 0) return failure('Cannot divide by zero');
320
+ const fundedSource = ensureSufficientFunds(source.data, request.amountCents);
321
+ if (fundedSource.isFailure) return fundedSource;
218
322
 
219
- return success(a / b);
323
+ return success({ ...request, feeCents: 25 });
220
324
  }
325
+ ```
221
326
 
222
- const result = run(function* ({ get }) {
223
- const first = yield* get(divide(20, 2));
224
- const second = yield* get(divide(first, 5));
225
-
226
- return success(second);
227
- });
327
+ With the same helpers, `run(...)` keeps the flow linear:
228
328
 
229
- if (result.isError) {
230
- console.error(result.error);
231
- } else {
232
- console.log(result.data); // 2
329
+ ```ts
330
+ import { run, success, type Failable } from '@pvorona/failable';
331
+
332
+ function planTransfer(
333
+ request: TransferRequest,
334
+ ): Failable<TransferPlan, TransferPlanningError> {
335
+ return run(function* ({ get }) {
336
+ const source = yield* get(readSourceAccount(request.fromAccountId));
337
+ const destination = yield* get(readDestinationAccount(request.toAccountId));
338
+ yield* get(ensureDifferentAccounts(source, destination));
339
+ yield* get(ensureSufficientFunds(source, request.amountCents));
340
+
341
+ return success({ ...request, feeCents: 25 });
342
+ });
233
343
  }
234
344
  ```
235
345
 
236
- Async builders use the same composition pattern. Keep using `yield* get(...)`; do not switch to `await get(...)`:
346
+ With one async rule added, the shape stays the same:
237
347
 
238
348
  ```ts
239
349
  import { failure, run, success, type Failable } from '@pvorona/failable';
240
350
 
241
- function divide(a: number, b: number): Failable<number, string> {
242
- if (b === 0) return failure('Cannot divide by zero');
351
+ type TransferAsyncError =
352
+ | TransferPlanningError
353
+ | { code: 'daily_limit_exceeded'; remainingCents: number };
243
354
 
244
- return success(a / b);
245
- }
355
+ const request = {
356
+ fromAccountId: 'checking',
357
+ toAccountId: 'savings',
358
+ amountCents: 2_500,
359
+ };
360
+
361
+ async function ensureWithinDailyLimit(
362
+ accountId: string,
363
+ amountCents: number,
364
+ ): Promise<Failable<void, TransferAsyncError>> {
365
+ const remainingCents = accountId === 'checking' ? 3_000 : 0;
366
+ if (amountCents > remainingCents) {
367
+ return failure({ code: 'daily_limit_exceeded', remainingCents });
368
+ }
246
369
 
247
- async function divideAsync(
248
- a: number,
249
- b: number,
250
- ): Promise<Failable<number, string>> {
251
- return divide(a, b);
370
+ return success(undefined);
252
371
  }
253
372
 
254
373
  const result = await run(async function* ({ get }) {
255
- const first = yield* get(divide(20, 2));
256
- const second = yield* get(divideAsync(first, 5));
374
+ const source = yield* get(readSourceAccount(request.fromAccountId));
375
+ const destination = yield* get(readDestinationAccount(request.toAccountId));
376
+ yield* get(ensureDifferentAccounts(source, destination));
377
+ yield* get(ensureSufficientFunds(source, request.amountCents));
378
+ yield* get(ensureWithinDailyLimit(source.id, request.amountCents));
257
379
 
258
- return success(second);
380
+ return success({ ...request, feeCents: 25 });
259
381
  });
260
-
261
- if (result.isError) {
262
- console.error(result.error);
263
- } else {
264
- console.log(result.data); // 2
265
- }
266
382
  ```
267
383
 
268
- Important `run(...)` rules:
384
+ Keep these rules in mind:
269
385
 
270
- - Use `run(...)` for `Failable`-to-`Failable` composition. Use `createFailable(...)` when you need to capture sync throws, promise rejections, or rehydrate a `FailableLike`.
271
- - In async builders, keep using `yield* get(...)`. Do not write `await get(...)`.
272
- - `get(...)` accepts `Failable` sources in both modes and `PromiseLike<Failable>` sources in async builders only.
273
- - Use `yield* get(failable)` inside the callback. Other interaction with `get` internals is unsupported and not part of the API contract.
274
- - `get` exists only inside the generator callback; it is not a public export.
275
- - Return `success(...)`, `failure(...)`, or another `Failable`. An empty generator or bare `return` becomes `Success<void>`, but raw return values are rejected.
276
- - Throwing inside the generator is not converted into `Failure`, and rejected promised sources are not converted into `Failure`; foreign exceptions and rejections escape unchanged.
277
- - Rare cleanup edge cases: cleanup `yield* get(...)` steps unwind before a yielded `Failure` returns, cleanup `Failure`s preserve the original `Failure` while outer `finally` blocks keep unwinding, and promised source rejections still unwind managed cleanup before rejecting. Direct `throw`s inside `finally` are outside that managed cleanup and can replace the in-flight failure or rejection.
386
+ - `run(...)` composes existing `Failable` values
387
+ - if a yielded step fails, `run(...)` returns that original failure unchanged
388
+ - in async builders, keep using `yield* get(...)`; do not write `await get(...)`
389
+ - `run(...)` does not capture thrown values or rejected promises into `Failure`
390
+ - wrap throwing or rejecting boundaries with `failable(...)` before they
391
+ enter `run(...)`
278
392
 
279
- ### Use guards for `unknown` values
393
+ ## Transport And Runtime Validation
280
394
 
281
- Use `isFailable(...)`, `isSuccess(...)`, and `isFailure(...)` when you start from `unknown` and need to validate something that might already be a hydrated `Failable` instance. If you already have a local result from your own code, keep branching with `result.isSuccess` / `result.isError` instead of re-validating it with top-level guards:
395
+ `Failable` values are hydrated objects with methods. Keep them inside your
396
+ process. If you need a structured-clone-friendly shape, convert to
397
+ `FailableLike<T, E>` before crossing the boundary and rehydrate on the other
398
+ side:
282
399
 
283
400
  ```ts
284
- import { isFailable } from '@pvorona/failable';
285
-
286
- const candidate: unknown = maybeFromAnotherModule();
287
-
288
- if (isFailable(candidate) && candidate.isError) {
289
- console.error(candidate.error);
290
- }
291
- ```
292
-
293
- These guards only recognize tagged hydrated instances created by `success(...)`, `failure(...)`, or `createFailable(...)`. Plain objects that merely look similar are not enough.
294
-
295
- Use `isSuccess(...)` / `isFailure(...)` when you only care about one branch. If you are validating plain wire data, use `isFailableLike(...)` and then rehydrate with `createFailable(...)` before calling instance methods.
296
-
297
- ## Important Semantics
298
-
299
- - Hydrated `Failable` values are frozen plain objects with methods. Prefer `result.isSuccess` / `result.isError`, and do not use `instanceof`.
300
- - `run(...)` supports both `function*` and `async function*` builders. In both forms, use `yield* get(...)`; async builders still do not use `await get(...)`.
301
- - `run(...)` short-circuits on the first yielded failure, preserves that original `Failure` instance unchanged, and enters `finally` cleanup before returning. Cleanup keeps unwinding while cleanup `yield* get(...)` steps succeed. Cleanup `Failure`s preserve the original `Failure` and continue into outer `finally` blocks, while promised cleanup rejections still escape unchanged.
302
- - In async builders, promised `get(...)` source rejections still escape unchanged after managed `yield* get(...)` cleanup unwinds. Managed cleanup `Failure`s and managed cleanup promise rejections do not replace that original rejection. Direct `throw`s inside `finally` are outside managed cleanup: they still escape unchanged, and they can replace an in-flight yielded `Failure` or main-path rejection.
303
- - `run(...)` composes existing `Failable` results only. It does not capture thrown values or rejected promises into `Failure`.
304
- - `or(...)` and `getOr(...)` are eager. The fallback expression runs before the method call.
305
- - `orElse(...)` and `getOrElse(...)` are lazy. The callback runs only on failure.
306
- - `match(onSuccess, onFailure)` is useful when both branches should converge to the same output type.
307
- - `throwIfError(result)` throws `result.error` unchanged on failures and narrows the same hydrated result to `Success<T>` on return.
308
- - `throwIfError(result)` is deliberately minimal. It does not normalize or map errors; if you want `Error` values, normalize earlier with `createFailable(..., NormalizedErrors)` or a custom `normalizeError`.
309
- - `isFailable(...)`, `isSuccess(...)`, and `isFailure(...)` recognize only tagged hydrated instances, not public-shape lookalikes.
310
- - `isFailableLike(...)` remains the validator for transport shapes, and `createFailable(failableLike)` is the supported rehydration path before calling instance methods.
311
- - `createFailable(...)` remains the boundary tool for capture. Use `createFailable(() => ...)` for synchronous throw capture and `await createFailable(promise)` for async rejection capture.
312
- - By default, `createFailable(...)` preserves raw thrown and rejected values. If something throws `'boom'`, `{ code: 'bad_request' }`, or `[error1, error2]`, that exact value becomes `.error`.
313
- - `getOrThrow()` returns the success value and throws `result.error` unchanged on failures. Use `throwIfError(result)` when you want control-flow narrowing instead of a returned value.
314
- - `createFailable(() => ...)` is for genuinely synchronous callbacks. TypeScript rejects obviously promise-returning callbacks, but JS callers and `any`/`unknown`-typed callbacks still rely on the runtime guard. If you already have a promise, pass it directly: `await createFailable(promise)`. That guard error stays actionable even when a custom `normalizeError` is present.
315
-
316
- ## Normalizing Errors
317
-
318
- If you want `Error`-shaped failures, opt in explicitly with `NormalizedErrors`:
319
-
320
- ```ts
321
- import { createFailable, NormalizedErrors } from '@pvorona/failable';
401
+ import {
402
+ failable,
403
+ toFailableLike,
404
+ } from '@pvorona/failable';
322
405
 
323
- const result = createFailable(
324
- () => {
325
- throw { code: 'bad_request' };
406
+ const result = planTransfer(
407
+ {
408
+ fromAccountId: 'checking',
409
+ toAccountId: 'savings',
410
+ amountCents: 2_500,
326
411
  },
327
- NormalizedErrors
412
+ 5_000,
328
413
  );
329
414
 
330
- if (result.isError) {
331
- console.error(result.error.message);
332
- console.error(result.error.cause); // { code: 'bad_request' }
333
- }
415
+ const wire = toFailableLike(result);
416
+ const hydrated = failable(wire);
334
417
  ```
335
418
 
336
- The same preset also normalizes existing `failure(...)` values and rehydrated `FailableLike`
337
- failures, while still passing through existing `Error` instances unchanged.
338
-
339
- Built-in normalization behaves like this:
340
-
341
- - existing `Error` values pass through unchanged
342
- - arrays become `AggregateError`
343
- - other values become `Error`
344
- - the original raw value is preserved in `error.cause`
345
-
346
- Custom normalization is different: `normalizeError` runs for failure values, including
347
- existing `Error` instances, so you can wrap or replace them. The one exception is the
348
- internal sync-only callback misuse guard error from `createFailable(() => promise)`,
349
- which is preserved as-is so the actionable message survives.
350
-
351
- For custom normalization:
419
+ Use the runtime guards only when the input did not come from your own local
420
+ control flow:
352
421
 
353
422
  ```ts
354
- import { createFailable } from '@pvorona/failable';
355
-
356
- const result = createFailable(doThing, {
357
- normalizeError(error) {
358
- return new Error('Operation failed', { cause: error });
359
- },
360
- });
361
- ```
362
-
363
- ## Boundary Transport
364
-
365
- Hydrated `Failable` values do not survive structured cloning because they carry methods and runtime details. If you need to cross a message boundary, convert to a plain shape first and rehydrate on the receiving side:
423
+ import { isFailable } from '@pvorona/failable';
366
424
 
367
- ```ts
368
- import { createFailable, toFailableLike } from '@pvorona/failable';
425
+ const candidate: unknown = maybeFromAnotherModule();
369
426
 
370
- const wire = toFailableLike(result);
371
- const hydrated = createFailable(wire);
427
+ if (isFailable(candidate) && candidate.isFailure) {
428
+ console.error(candidate.error);
429
+ }
372
430
  ```
373
431
 
374
- `isFailableLike(...)` validates the strict wire shape `{ status, data }` or `{ status, error }`, and the inner `data` / `error` values must still be structured-cloneable.
432
+ - use `isFailable(...)`, `isSuccess(...)`, and `isFailure(...)` for `unknown`
433
+ values that might already be hydrated `Failable` results
434
+ - use `isFailableLike(...)` for plain transport shapes like
435
+ `{ status, data }` or `{ status, error }`
375
436
 
376
437
  ## API At A Glance
377
438
 
378
439
  - `type Failable<T, E>`: `Success<T> | Failure<E>`
379
- - `type Success<T>`: success variant with `isSuccess`, `data`, `or(...)`, `orElse(...)`, `getOr(...)`, `getOrElse(...)`, `match(...)`, and `getOrThrow()`
380
- - `type Failure<E>`: failure variant with `isError`, `error`, `or(...)`, `orElse(...)`, `getOr(...)`, `getOrElse(...)`, `match(...)`, and `getOrThrow()`
381
- - `type FailableLike<T, E>`: strict structured-clone-friendly wire shape
382
- - `const NormalizedErrors`: built-in token for `Error` normalization
383
- - `success(data)` / `failure(error)`: explicit constructors
384
- - `throwIfError(result)`: throw the stored failure unchanged and narrow the same result on success
385
- - `run(...)`: compose sync or async `Failable` steps with short-circuiting
386
- - `createFailable(...)`: wrap, preserve, rehydrate, or normalize results
387
- - `isFailable(...)`, `isSuccess(...)`, `isFailure(...)`: runtime validators for tagged hydrated values
388
- - `toFailableLike(...)`: convert a hydrated result into a plain transport shape
389
- - `isFailableLike(...)`: validate the strict wire shape
390
- - `const FailableStatus`: runtime `{ Success, Failure }` object for wire values
440
+ - `type Success<T>` / `type Failure<E>`: hydrated result variants
441
+ - `type FailableLike<T, E>`: structured-clone-friendly wire shape
442
+ - `success(data)` / `failure(error)`: create hydrated results
443
+ - `throwIfError(result)` / `result.getOrThrow()`: throw the stored failure
444
+ unchanged
445
+ - `failable(...)`: preserve, rehydrate, capture, or normalize failures at
446
+ a boundary
447
+ - `run(...)`: compose `Failable` steps without nested branching
448
+ - `toFailableLike(...)`: convert a hydrated result into a wire shape
449
+ - `isFailableLike(...)`: validate a wire shape
450
+ - `isFailable(...)`, `isSuccess(...)`, `isFailure(...)`: validate hydrated
451
+ results
452
+ - `NormalizedErrors`: built-in `Error` normalization for `failable(...)`
453
+ - `FailableStatus`: runtime success/failure status values