@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 +327 -267
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +146 -124
- package/dist/lib/failable.d.ts +60 -38
- package/dist/lib/failable.d.ts.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
Typed success/failure results for expected failures in TypeScript.
|
|
4
4
|
|
|
5
|
-
`
|
|
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
|
|
21
|
+
This package is ESM-only and requires Node 18+.
|
|
14
22
|
|
|
15
|
-
##
|
|
23
|
+
## Migration Note
|
|
16
24
|
|
|
17
|
-
|
|
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
|
-
|
|
22
|
-
|
|
27
|
+
- `createFailable(x)` -> `failable(x)`
|
|
28
|
+
- `CreateFailableNormalizeErrorOptions` -> `FailableNormalizeErrorOptions`
|
|
23
29
|
|
|
24
|
-
|
|
25
|
-
}
|
|
30
|
+
## Basic Usage
|
|
26
31
|
|
|
27
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
} else {
|
|
32
|
-
console.log(result.data);
|
|
33
|
-
}
|
|
34
|
-
```
|
|
36
|
+
```ts
|
|
37
|
+
import { failure, success, type Failable } from '@pvorona/failable';
|
|
35
38
|
|
|
36
|
-
|
|
39
|
+
type TransferRequest = {
|
|
40
|
+
fromAccountId: string;
|
|
41
|
+
toAccountId: string;
|
|
42
|
+
amountCents: number;
|
|
43
|
+
};
|
|
37
44
|
|
|
38
|
-
|
|
45
|
+
type TransferPlan = TransferRequest & {
|
|
46
|
+
feeCents: number;
|
|
47
|
+
};
|
|
39
48
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
66
|
+
if (request.amountCents < 100) {
|
|
67
|
+
return failure({ code: 'amount_too_small', minAmountCents: 100 });
|
|
68
|
+
}
|
|
47
69
|
|
|
48
|
-
|
|
70
|
+
if (balanceCents < request.amountCents) {
|
|
71
|
+
return failure({
|
|
72
|
+
code: 'insufficient_funds',
|
|
73
|
+
balanceCents,
|
|
74
|
+
attemptedCents: request.amountCents,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
49
77
|
|
|
50
|
-
|
|
51
|
-
|
|
78
|
+
return success({ ...request, feeCents: 25 });
|
|
79
|
+
}
|
|
52
80
|
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
81
|
+
const result = planTransfer(
|
|
82
|
+
{
|
|
83
|
+
fromAccountId: 'checking',
|
|
84
|
+
toAccountId: 'savings',
|
|
85
|
+
amountCents: 10_000,
|
|
86
|
+
},
|
|
87
|
+
4_500,
|
|
88
|
+
);
|
|
56
89
|
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
import { failure, success } from '@pvorona/failable';
|
|
112
|
+
## Choose The Right API
|
|
63
113
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
69
|
-
console.error(portResult.error);
|
|
70
|
-
} else {
|
|
71
|
-
console.log(portResult.data);
|
|
72
|
-
}
|
|
126
|
+
## Unwrapping And Recovery
|
|
73
127
|
|
|
74
|
-
|
|
75
|
-
|
|
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.
|
|
80
|
-
- `result.
|
|
81
|
-
- `result.
|
|
82
|
-
- `result.
|
|
83
|
-
- `result.
|
|
84
|
-
- `result.
|
|
85
|
-
- `throwIfError(result)`: throw
|
|
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
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
149
|
+
type QuoteError = {
|
|
150
|
+
code: 'pricing_unavailable';
|
|
151
|
+
};
|
|
101
152
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
153
|
+
const feeResult: Failable<number, QuoteError> =
|
|
154
|
+
Math.random() > 0.5
|
|
155
|
+
? success(25)
|
|
156
|
+
: failure({ code: 'pricing_unavailable' });
|
|
105
157
|
|
|
106
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
console.log(requiredPort);
|
|
164
|
+
throwIfError(feeResult);
|
|
165
|
+
console.log(feeCents, status, feeResult.data);
|
|
111
166
|
```
|
|
112
167
|
|
|
113
|
-
|
|
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 {
|
|
176
|
+
import {
|
|
177
|
+
failable,
|
|
178
|
+
run,
|
|
179
|
+
success,
|
|
180
|
+
type Failable,
|
|
181
|
+
} from '@pvorona/failable';
|
|
117
182
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
209
|
+
return success(created);
|
|
210
|
+
});
|
|
211
|
+
}
|
|
132
212
|
```
|
|
133
213
|
|
|
134
|
-
`
|
|
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
|
-
|
|
221
|
+
Pass a promise directly when you want rejection capture:
|
|
137
222
|
|
|
138
223
|
```ts
|
|
139
|
-
|
|
224
|
+
const responseResult = await failable(fetch(url));
|
|
225
|
+
```
|
|
140
226
|
|
|
141
|
-
|
|
142
|
-
? success(3000)
|
|
143
|
-
: failure('Missing port');
|
|
227
|
+
`failable(...)` can:
|
|
144
228
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
235
|
+
By default, the thrown or rejected value becomes `.error` unchanged.
|
|
152
236
|
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
|
|
243
|
+
Use `run(...)` when each step already returns `Failable` and you want to write
|
|
244
|
+
the success path once:
|
|
162
245
|
|
|
163
|
-
|
|
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
|
|
174
|
-
|
|
251
|
+
type Account = {
|
|
252
|
+
id: string;
|
|
253
|
+
balanceCents: number;
|
|
175
254
|
};
|
|
176
255
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
} else {
|
|
188
|
-
const portResult = readPort(configResult.data.port);
|
|
268
|
+
return success({ id: 'checking', balanceCents: 5_000 });
|
|
269
|
+
}
|
|
189
270
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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
|
-
|
|
201
|
-
|
|
287
|
+
return success(undefined);
|
|
288
|
+
}
|
|
202
289
|
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
302
|
+
return success(source);
|
|
303
|
+
}
|
|
208
304
|
|
|
209
|
-
|
|
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
|
-
|
|
311
|
+
const destination = readDestinationAccount(request.toAccountId);
|
|
312
|
+
if (destination.isError) return destination;
|
|
212
313
|
|
|
213
|
-
|
|
214
|
-
|
|
314
|
+
const differentAccounts = ensureDifferentAccounts(source.data, destination.data);
|
|
315
|
+
if (differentAccounts.isError) return differentAccounts;
|
|
215
316
|
|
|
216
|
-
|
|
217
|
-
if (
|
|
317
|
+
const fundedSource = ensureSufficientFunds(source.data, request.amountCents);
|
|
318
|
+
if (fundedSource.isError) return fundedSource;
|
|
218
319
|
|
|
219
|
-
return success(
|
|
320
|
+
return success({ ...request, feeCents: 25 });
|
|
220
321
|
}
|
|
322
|
+
```
|
|
221
323
|
|
|
222
|
-
|
|
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
|
-
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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
|
-
|
|
242
|
-
|
|
348
|
+
type TransferAsyncError =
|
|
349
|
+
| TransferPlanningError
|
|
350
|
+
| { code: 'daily_limit_exceeded'; remainingCents: number };
|
|
243
351
|
|
|
244
|
-
|
|
245
|
-
|
|
352
|
+
const request = {
|
|
353
|
+
fromAccountId: 'checking',
|
|
354
|
+
toAccountId: 'savings',
|
|
355
|
+
amountCents: 2_500,
|
|
356
|
+
};
|
|
246
357
|
|
|
247
|
-
async function
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
): Promise<Failable<
|
|
251
|
-
|
|
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
|
|
256
|
-
const
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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 {
|
|
398
|
+
import {
|
|
399
|
+
failable,
|
|
400
|
+
toFailableLike,
|
|
401
|
+
} from '@pvorona/failable';
|
|
322
402
|
|
|
323
|
-
const result =
|
|
324
|
-
|
|
325
|
-
|
|
403
|
+
const result = planTransfer(
|
|
404
|
+
{
|
|
405
|
+
fromAccountId: 'checking',
|
|
406
|
+
toAccountId: 'savings',
|
|
407
|
+
amountCents: 2_500,
|
|
326
408
|
},
|
|
327
|
-
|
|
409
|
+
5_000,
|
|
328
410
|
);
|
|
329
411
|
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
337
|
-
|
|
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 {
|
|
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
|
-
|
|
368
|
-
import { createFailable, toFailableLike } from '@pvorona/failable';
|
|
422
|
+
const candidate: unknown = maybeFromAnotherModule();
|
|
369
423
|
|
|
370
|
-
|
|
371
|
-
|
|
424
|
+
if (isFailable(candidate) && candidate.isError) {
|
|
425
|
+
console.error(candidate.error);
|
|
426
|
+
}
|
|
372
427
|
```
|
|
373
428
|
|
|
374
|
-
`
|
|
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
|
|
380
|
-
- `type
|
|
381
|
-
- `
|
|
382
|
-
- `
|
|
383
|
-
|
|
384
|
-
- `
|
|
385
|
-
|
|
386
|
-
- `
|
|
387
|
-
- `
|
|
388
|
-
- `
|
|
389
|
-
- `
|
|
390
|
-
|
|
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
|