@pvorona/failable 0.4.0 → 0.5.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,125 @@ 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`
23
29
 
24
- return success(a / b);
25
- }
30
+ ## Basic Usage
26
31
 
27
- const result = divide(10, 2);
32
+ Use `Failable` when callers need different behavior for different expected
33
+ failures. This is especially useful for business rules that schema validators do
34
+ not model for you:
28
35
 
29
- if (result.isError) {
30
- console.error(result.error);
31
- } else {
32
- console.log(result.data);
33
- }
34
- ```
36
+ ```ts
37
+ import { failure, success, type Failable } from '@pvorona/failable';
35
38
 
36
- ## Everyday Usage
39
+ type TransferRequest = {
40
+ fromAccountId: string;
41
+ toAccountId: string;
42
+ amountCents: number;
43
+ };
37
44
 
38
- ### Quick chooser
45
+ type TransferPlan = TransferRequest & {
46
+ feeCents: number;
47
+ };
39
48
 
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.
49
+ type TransferError =
50
+ | { code: 'same_account' }
51
+ | { code: 'amount_too_small'; minAmountCents: number }
52
+ | {
53
+ code: 'insufficient_funds';
54
+ balanceCents: number;
55
+ attemptedCents: number;
56
+ };
57
+
58
+ function planTransfer(
59
+ request: TransferRequest,
60
+ balanceCents: number,
61
+ ): Failable<TransferPlan, TransferError> {
62
+ if (request.fromAccountId === request.toAccountId) {
63
+ return failure({ code: 'same_account' });
64
+ }
45
65
 
46
- ### Return `success(...)` and `failure(...)`
66
+ if (request.amountCents < 100) {
67
+ return failure({ code: 'amount_too_small', minAmountCents: 100 });
68
+ }
47
69
 
48
- Use the explicit constructors when your function already knows which branch it should return:
70
+ if (balanceCents < request.amountCents) {
71
+ return failure({
72
+ code: 'insufficient_funds',
73
+ balanceCents,
74
+ attemptedCents: request.amountCents,
75
+ });
76
+ }
49
77
 
50
- ```ts
51
- import { failure, success } from '@pvorona/failable';
78
+ return success({ ...request, feeCents: 25 });
79
+ }
52
80
 
53
- const ok = success({ id: '1' });
54
- const err = failure({ code: 'bad_request' });
55
- ```
81
+ const result = planTransfer(
82
+ {
83
+ fromAccountId: 'checking',
84
+ toAccountId: 'savings',
85
+ amountCents: 10_000,
86
+ },
87
+ 4_500,
88
+ );
56
89
 
57
- ### Branch and unwrap with helpers
90
+ if (result.isError) {
91
+ switch (result.error.code) {
92
+ case 'same_account':
93
+ console.error('Choose a different destination account');
94
+ break;
95
+ case 'amount_too_small':
96
+ console.error(`Transfers start at ${result.error.minAmountCents} cents`);
97
+ break;
98
+ case 'insufficient_funds':
99
+ console.error(
100
+ `Balance ${result.error.balanceCents} is below ${result.error.attemptedCents}`
101
+ );
102
+ break;
103
+ }
104
+ } else {
105
+ console.log(result.data.feeCents);
106
+ }
107
+ ```
58
108
 
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:
109
+ That is the default usage model for this package: return a typed result that
110
+ carries both the success value and the expected failure reason.
60
111
 
61
- ```ts
62
- import { failure, success } from '@pvorona/failable';
112
+ ## Choose The Right API
63
113
 
64
- const portResult = Math.random() > 0.5
65
- ? success(3000)
66
- : failure('Missing port');
114
+ | Need | Use |
115
+ | --- | --- |
116
+ | Return a successful or failed result from your own code | `success(...)` / `failure(...)` |
117
+ | Read the value or provide a fallback | `getOr(...)` / `getOrElse(...)` |
118
+ | Recover to `Success<T>` | `or(...)` / `orElse(...)` |
119
+ | Map both branches to one output | `match(onSuccess, onFailure)` |
120
+ | Throw the stored failure unchanged | `getOrThrow()` / `throwIfError(result)` |
121
+ | Capture a throwing or rejecting boundary | `failable(...)` |
122
+ | Compose multiple `Failable` steps | `run(...)` |
123
+ | Cross a structured-clone boundary | `toFailableLike(...)` + `failable(...)` |
124
+ | Validate `unknown` input | `isFailable(...)`, `isSuccess(...)`, `isFailure(...)`, `isFailableLike(...)` |
67
125
 
68
- if (portResult.isError) {
69
- console.error(portResult.error);
70
- } else {
71
- console.log(portResult.data);
72
- }
126
+ ## Unwrapping And Recovery
73
127
 
74
- const port = portResult.getOr(3000);
75
- const ensuredPort = portResult.or(3000);
76
- console.log(port, ensuredPort.data);
77
- ```
128
+ Start with ordinary branching on `result.isError` or `result.isSuccess`. When
129
+ you need a shorter form, use the helper that matches the job:
78
130
 
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
131
+ - `result.getOr(fallback)`: return the success value or an eager fallback
132
+ - `result.getOrElse(() => fallback)`: same, but lazily
133
+ - `result.or(fallback)`: recover to `Success<T>` with an eager fallback
134
+ - `result.orElse(() => fallback)`: recover to `Success<T>` lazily
135
+ - `result.match(onSuccess, onFailure)`: map both branches to one output
136
+ - `result.getOrThrow()`: return the success value or throw `result.error`
137
+ - `throwIfError(result)`: throw `result.error` and narrow the same variable
87
138
 
88
- Use `throwIfError(result)` when you want to keep the same result variable and continue with narrowed access to `result.data`:
139
+ Use the lazy forms when the fallback is expensive or has side effects.
89
140
 
90
141
  ```ts
91
142
  import {
@@ -95,296 +146,305 @@ import {
95
146
  type Failable,
96
147
  } from '@pvorona/failable';
97
148
 
98
- const portResult: Failable<number, string> = Math.random() > 0.5
99
- ? success(3000)
100
- : failure('Missing port');
149
+ type QuoteError = {
150
+ code: 'pricing_unavailable';
151
+ };
101
152
 
102
- throwIfError(portResult);
103
- console.log(portResult.data);
104
- ```
153
+ const feeResult: Failable<number, QuoteError> =
154
+ Math.random() > 0.5
155
+ ? success(25)
156
+ : failure({ code: 'pricing_unavailable' });
105
157
 
106
- Use `getOrThrow()` when you want the success value itself in expression or return position:
158
+ const feeCents = feeResult.getOr(25);
159
+ const status = feeResult.match(
160
+ (value) => `Fee is ${value} cents`,
161
+ (error) => `Cannot quote fee: ${error.code}`
162
+ );
107
163
 
108
- ```ts
109
- const requiredPort = portResult.getOrThrow();
110
- console.log(requiredPort);
164
+ throwIfError(feeResult);
165
+ console.log(feeCents, status, feeResult.data);
111
166
  ```
112
167
 
113
- Use the lazy forms when the fallback is expensive or has side effects:
168
+ ## Capture Thrown Or Rejected Failures With `failable(...)`
169
+
170
+ Use `failable(...)` at boundaries that throw or reject, then normalize
171
+ that failure once at the boundary if needed:
172
+
173
+ Using `TransferPlan` from above:
114
174
 
115
175
  ```ts
116
- import { failure, success } from '@pvorona/failable';
176
+ import {
177
+ failable,
178
+ run,
179
+ success,
180
+ type Failable,
181
+ } from '@pvorona/failable';
117
182
 
118
- function readFallbackPort() {
119
- console.log('Reading fallback port from disk');
120
- return 3000;
121
- }
183
+ type SubmitTransferError = Error;
184
+
185
+ async function postToLedger(
186
+ plan: TransferPlan,
187
+ ): Promise<Failable<{ transferId: string }, SubmitTransferError>> {
188
+ const request = (async () => {
189
+ if (plan.amountCents > 5_000) {
190
+ throw { code: 'ledger_unavailable' } as const;
191
+ }
122
192
 
123
- const portResult = Math.random() > 0.5
124
- ? success(8080)
125
- : failure('Missing port');
193
+ return { transferId: 'tr_123' };
194
+ })();
195
+
196
+ return await failable(request, {
197
+ normalizeError(error) {
198
+ return new Error('Ledger unavailable', { cause: error });
199
+ },
200
+ });
201
+ }
126
202
 
127
- const eagerPort = portResult.getOr(readFallbackPort());
128
- const lazyPort = portResult.getOrElse(() => readFallbackPort());
129
- const ensuredPort = portResult.orElse(() => readFallbackPort());
203
+ async function submitTransfer(
204
+ plan: TransferPlan,
205
+ ): Promise<Failable<{ transferId: string }, SubmitTransferError>> {
206
+ return await run(async function* ({ get }) {
207
+ const created = yield* get(postToLedger(plan));
130
208
 
131
- console.log(eagerPort, lazyPort, ensuredPort.data);
209
+ return success(created);
210
+ });
211
+ }
132
212
  ```
133
213
 
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.
214
+ `postToLedger(...)` is the boundary adapter. It uses
215
+ `failable(..., { normalizeError })` to capture a raw throw/rejection once
216
+ and expose one stable `Error` shape to the rest of the app. If you only need
217
+ generic `Error` normalization, `NormalizedErrors` is the built-in shortcut.
218
+ Once that helper already returns `Failable`, `submitTransfer(...)` can use
219
+ `run(...)` to compose it like any other step.
135
220
 
136
- `match(...)` is often clearer than a fallback when both branches need real handling:
221
+ Pass a promise directly when you want rejection capture:
137
222
 
138
223
  ```ts
139
- import { failure, success } from '@pvorona/failable';
224
+ const responseResult = await failable(fetch(url));
225
+ ```
140
226
 
141
- const portResult = Math.random() > 0.5
142
- ? success(3000)
143
- : failure('Missing port');
227
+ `failable(...)` can:
144
228
 
145
- const status = portResult.match(
146
- (port) => `Listening on ${port}`,
147
- (error) => `Cannot start server: ${error}`
148
- );
149
- ```
229
+ - preserve an existing `Failable`
230
+ - rehydrate a `FailableLike`
231
+ - capture sync throws from a callback
232
+ - capture promise rejections from a promise
233
+ - normalize failures with `NormalizedErrors` or a custom `normalizeError(...)`
150
234
 
151
- ### `createFailable(...)` for throwy or rejecting code
235
+ By default, the thrown or rejected value becomes `.error` unchanged.
152
236
 
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:
237
+ Pass the promise itself when you want rejection capture.
238
+ `failable(async () => value)` is misuse and returns a `Failure<Error>` telling
239
+ you to pass the promise directly instead.
154
240
 
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`
241
+ ## Compose Existing `Failable` Steps With `run(...)`
160
242
 
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.
243
+ Use `run(...)` when each step already returns `Failable` and you want to write
244
+ the success path once:
162
245
 
163
- If a helper already returns `Failable`, branch on it directly or compose it with `run(...)` instead of wrapping it again with `createFailable(...)`.
246
+ Without `run(...)`, composition often becomes an error ladder:
164
247
 
165
248
  ```ts
166
- import {
167
- createFailable,
168
- failure,
169
- success,
170
- type Failable,
171
- } from '@pvorona/failable';
249
+ import { failure, success, type Failable } from '@pvorona/failable';
172
250
 
173
- type PortError = {
174
- readonly code: 'invalid_port';
251
+ type Account = {
252
+ id: string;
253
+ balanceCents: number;
175
254
  };
176
255
 
177
- function readPort(value: unknown): Failable<number, PortError> {
178
- if (typeof value !== 'number') return failure({ code: 'invalid_port' });
179
-
180
- return success(value);
181
- }
256
+ type TransferPlanningError =
257
+ | TransferError
258
+ | { code: 'source_account_not_found'; accountId: string }
259
+ | { code: 'destination_account_not_found'; accountId: string };
182
260
 
183
- const configResult = createFailable(() => JSON.parse(rawConfig));
261
+ function readSourceAccount(
262
+ accountId: string,
263
+ ): Failable<Account, TransferPlanningError> {
264
+ if (accountId !== 'checking') {
265
+ return failure({ code: 'source_account_not_found', accountId });
266
+ }
184
267
 
185
- if (configResult.isError) {
186
- console.error('Invalid JSON:', configResult.error);
187
- } else {
188
- const portResult = readPort(configResult.data.port);
268
+ return success({ id: 'checking', balanceCents: 5_000 });
269
+ }
189
270
 
190
- if (portResult.isError) {
191
- console.error(portResult.error.code);
192
- } else {
193
- console.log(portResult.data);
271
+ function readDestinationAccount(
272
+ accountId: string,
273
+ ): Failable<Account, TransferPlanningError> {
274
+ if (accountId !== 'savings') {
275
+ return failure({ code: 'destination_account_not_found', accountId });
194
276
  }
277
+
278
+ return success({ id: 'savings', balanceCents: 20_000 });
195
279
  }
196
- ```
197
280
 
198
- Pass promises directly when you want rejection capture:
281
+ function ensureDifferentAccounts(
282
+ source: Account,
283
+ destination: Account,
284
+ ): Failable<void, TransferPlanningError> {
285
+ if (source.id === destination.id) return failure({ code: 'same_account' });
199
286
 
200
- ```ts
201
- import { createFailable } from '@pvorona/failable';
287
+ return success(undefined);
288
+ }
202
289
 
203
- const responseResult = await createFailable(fetch(url));
204
- if (responseResult.isError) console.error(responseResult.error);
205
- ```
290
+ function ensureSufficientFunds(
291
+ source: Account,
292
+ amountCents: number,
293
+ ): Failable<Account, TransferPlanningError> {
294
+ if (source.balanceCents < amountCents) {
295
+ return failure({
296
+ code: 'insufficient_funds',
297
+ balanceCents: source.balanceCents,
298
+ attemptedCents: amountCents,
299
+ });
300
+ }
206
301
 
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`.
302
+ return success(source);
303
+ }
208
304
 
209
- ### `run(...)` for `Failable` composition
305
+ function planTransfer(
306
+ request: TransferRequest,
307
+ ): Failable<TransferPlan, TransferPlanningError> {
308
+ const source = readSourceAccount(request.fromAccountId);
309
+ if (source.isError) return source;
210
310
 
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.
311
+ const destination = readDestinationAccount(request.toAccountId);
312
+ if (destination.isError) return destination;
212
313
 
213
- ```ts
214
- import { failure, run, success, type Failable } from '@pvorona/failable';
314
+ const differentAccounts = ensureDifferentAccounts(source.data, destination.data);
315
+ if (differentAccounts.isError) return differentAccounts;
215
316
 
216
- function divide(a: number, b: number): Failable<number, string> {
217
- if (b === 0) return failure('Cannot divide by zero');
317
+ const fundedSource = ensureSufficientFunds(source.data, request.amountCents);
318
+ if (fundedSource.isError) return fundedSource;
218
319
 
219
- return success(a / b);
320
+ return success({ ...request, feeCents: 25 });
220
321
  }
322
+ ```
221
323
 
222
- const result = run(function* ({ get }) {
223
- const first = yield* get(divide(20, 2));
224
- const second = yield* get(divide(first, 5));
324
+ With the same helpers, `run(...)` keeps the flow linear:
225
325
 
226
- return success(second);
227
- });
228
-
229
- if (result.isError) {
230
- console.error(result.error);
231
- } else {
232
- console.log(result.data); // 2
326
+ ```ts
327
+ import { run, success, type Failable } from '@pvorona/failable';
328
+
329
+ function planTransfer(
330
+ request: TransferRequest,
331
+ ): Failable<TransferPlan, TransferPlanningError> {
332
+ return run(function* ({ get }) {
333
+ const source = yield* get(readSourceAccount(request.fromAccountId));
334
+ const destination = yield* get(readDestinationAccount(request.toAccountId));
335
+ yield* get(ensureDifferentAccounts(source, destination));
336
+ yield* get(ensureSufficientFunds(source, request.amountCents));
337
+
338
+ return success({ ...request, feeCents: 25 });
339
+ });
233
340
  }
234
341
  ```
235
342
 
236
- Async builders use the same composition pattern. Keep using `yield* get(...)`; do not switch to `await get(...)`:
343
+ With one async rule added, the shape stays the same:
237
344
 
238
345
  ```ts
239
346
  import { failure, run, success, type Failable } from '@pvorona/failable';
240
347
 
241
- function divide(a: number, b: number): Failable<number, string> {
242
- if (b === 0) return failure('Cannot divide by zero');
348
+ type TransferAsyncError =
349
+ | TransferPlanningError
350
+ | { code: 'daily_limit_exceeded'; remainingCents: number };
243
351
 
244
- return success(a / b);
245
- }
352
+ const request = {
353
+ fromAccountId: 'checking',
354
+ toAccountId: 'savings',
355
+ amountCents: 2_500,
356
+ };
246
357
 
247
- async function divideAsync(
248
- a: number,
249
- b: number,
250
- ): Promise<Failable<number, string>> {
251
- return divide(a, b);
358
+ async function ensureWithinDailyLimit(
359
+ accountId: string,
360
+ amountCents: number,
361
+ ): Promise<Failable<void, TransferAsyncError>> {
362
+ const remainingCents = accountId === 'checking' ? 3_000 : 0;
363
+ if (amountCents > remainingCents) {
364
+ return failure({ code: 'daily_limit_exceeded', remainingCents });
365
+ }
366
+
367
+ return success(undefined);
252
368
  }
253
369
 
254
370
  const result = await run(async function* ({ get }) {
255
- const first = yield* get(divide(20, 2));
256
- const second = yield* get(divideAsync(first, 5));
371
+ const source = yield* get(readSourceAccount(request.fromAccountId));
372
+ const destination = yield* get(readDestinationAccount(request.toAccountId));
373
+ yield* get(ensureDifferentAccounts(source, destination));
374
+ yield* get(ensureSufficientFunds(source, request.amountCents));
375
+ yield* get(ensureWithinDailyLimit(source.id, request.amountCents));
257
376
 
258
- return success(second);
377
+ return success({ ...request, feeCents: 25 });
259
378
  });
260
-
261
- if (result.isError) {
262
- console.error(result.error);
263
- } else {
264
- console.log(result.data); // 2
265
- }
266
- ```
267
-
268
- Important `run(...)` rules:
269
-
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.
278
-
279
- ### Use guards for `unknown` values
280
-
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:
282
-
283
- ```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
379
  ```
292
380
 
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.
381
+ Keep these rules in mind:
296
382
 
297
- ## Important Semantics
383
+ - `run(...)` composes existing `Failable` values
384
+ - if a yielded step fails, `run(...)` returns that original failure unchanged
385
+ - in async builders, keep using `yield* get(...)`; do not write `await get(...)`
386
+ - `run(...)` does not capture thrown values or rejected promises into `Failure`
387
+ - wrap throwing or rejecting boundaries with `failable(...)` before they
388
+ enter `run(...)`
298
389
 
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.
390
+ ## Transport And Runtime Validation
315
391
 
316
- ## Normalizing Errors
317
-
318
- If you want `Error`-shaped failures, opt in explicitly with `NormalizedErrors`:
392
+ `Failable` values are hydrated objects with methods. Keep them inside your
393
+ process. If you need a structured-clone-friendly shape, convert to
394
+ `FailableLike<T, E>` before crossing the boundary and rehydrate on the other
395
+ side:
319
396
 
320
397
  ```ts
321
- import { createFailable, NormalizedErrors } from '@pvorona/failable';
398
+ import {
399
+ failable,
400
+ toFailableLike,
401
+ } from '@pvorona/failable';
322
402
 
323
- const result = createFailable(
324
- () => {
325
- throw { code: 'bad_request' };
403
+ const result = planTransfer(
404
+ {
405
+ fromAccountId: 'checking',
406
+ toAccountId: 'savings',
407
+ amountCents: 2_500,
326
408
  },
327
- NormalizedErrors
409
+ 5_000,
328
410
  );
329
411
 
330
- if (result.isError) {
331
- console.error(result.error.message);
332
- console.error(result.error.cause); // { code: 'bad_request' }
333
- }
412
+ const wire = toFailableLike(result);
413
+ const hydrated = failable(wire);
334
414
  ```
335
415
 
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:
416
+ Use the runtime guards only when the input did not come from your own local
417
+ control flow:
352
418
 
353
419
  ```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:
420
+ import { isFailable } from '@pvorona/failable';
366
421
 
367
- ```ts
368
- import { createFailable, toFailableLike } from '@pvorona/failable';
422
+ const candidate: unknown = maybeFromAnotherModule();
369
423
 
370
- const wire = toFailableLike(result);
371
- const hydrated = createFailable(wire);
424
+ if (isFailable(candidate) && candidate.isError) {
425
+ console.error(candidate.error);
426
+ }
372
427
  ```
373
428
 
374
- `isFailableLike(...)` validates the strict wire shape `{ status, data }` or `{ status, error }`, and the inner `data` / `error` values must still be structured-cloneable.
429
+ - use `isFailable(...)`, `isSuccess(...)`, and `isFailure(...)` for `unknown`
430
+ values that might already be hydrated `Failable` results
431
+ - use `isFailableLike(...)` for plain transport shapes like
432
+ `{ status, data }` or `{ status, error }`
375
433
 
376
434
  ## API At A Glance
377
435
 
378
436
  - `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
437
+ - `type Success<T>` / `type Failure<E>`: hydrated result variants
438
+ - `type FailableLike<T, E>`: structured-clone-friendly wire shape
439
+ - `success(data)` / `failure(error)`: create hydrated results
440
+ - `throwIfError(result)` / `result.getOrThrow()`: throw the stored failure
441
+ unchanged
442
+ - `failable(...)`: preserve, rehydrate, capture, or normalize failures at
443
+ a boundary
444
+ - `run(...)`: compose `Failable` steps without nested branching
445
+ - `toFailableLike(...)`: convert a hydrated result into a wire shape
446
+ - `isFailableLike(...)`: validate a wire shape
447
+ - `isFailable(...)`, `isSuccess(...)`, `isFailure(...)`: validate hydrated
448
+ results
449
+ - `NormalizedErrors`: built-in `Error` normalization for `failable(...)`
450
+ - `FailableStatus`: runtime success/failure status values