@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 +330 -267
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +139 -117
- package/dist/lib/failable.d.ts +62 -40
- 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,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
|
|
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`
|
|
29
|
+
- `result.isError` -> `result.isFailure`
|
|
30
|
+
- `.error` is unchanged
|
|
31
|
+
- `failure(...)`, `Failure<E>`, `throwIfError(...)`, and `getOrThrow()` are unchanged
|
|
23
32
|
|
|
24
|
-
|
|
25
|
-
}
|
|
33
|
+
## Basic Usage
|
|
26
34
|
|
|
27
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
} else {
|
|
32
|
-
console.log(result.data);
|
|
33
|
-
}
|
|
34
|
-
```
|
|
39
|
+
```ts
|
|
40
|
+
import { failure, success, type Failable } from '@pvorona/failable';
|
|
35
41
|
|
|
36
|
-
|
|
42
|
+
type TransferRequest = {
|
|
43
|
+
fromAccountId: string;
|
|
44
|
+
toAccountId: string;
|
|
45
|
+
amountCents: number;
|
|
46
|
+
};
|
|
37
47
|
|
|
38
|
-
|
|
48
|
+
type TransferPlan = TransferRequest & {
|
|
49
|
+
feeCents: number;
|
|
50
|
+
};
|
|
39
51
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
69
|
+
if (request.amountCents < 100) {
|
|
70
|
+
return failure({ code: 'amount_too_small', minAmountCents: 100 });
|
|
71
|
+
}
|
|
47
72
|
|
|
48
|
-
|
|
73
|
+
if (balanceCents < request.amountCents) {
|
|
74
|
+
return failure({
|
|
75
|
+
code: 'insufficient_funds',
|
|
76
|
+
balanceCents,
|
|
77
|
+
attemptedCents: request.amountCents,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
49
80
|
|
|
50
|
-
|
|
51
|
-
|
|
81
|
+
return success({ ...request, feeCents: 25 });
|
|
82
|
+
}
|
|
52
83
|
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
84
|
+
const result = planTransfer(
|
|
85
|
+
{
|
|
86
|
+
fromAccountId: 'checking',
|
|
87
|
+
toAccountId: 'savings',
|
|
88
|
+
amountCents: 10_000,
|
|
89
|
+
},
|
|
90
|
+
4_500,
|
|
91
|
+
);
|
|
56
92
|
|
|
57
|
-
|
|
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
|
-
|
|
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
|
-
|
|
62
|
-
import { failure, success } from '@pvorona/failable';
|
|
115
|
+
## Choose The Right API
|
|
63
116
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
69
|
-
console.error(portResult.error);
|
|
70
|
-
} else {
|
|
71
|
-
console.log(portResult.data);
|
|
72
|
-
}
|
|
129
|
+
## Unwrapping And Recovery
|
|
73
130
|
|
|
74
|
-
|
|
75
|
-
|
|
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
152
|
+
type QuoteError = {
|
|
153
|
+
code: 'pricing_unavailable';
|
|
154
|
+
};
|
|
101
155
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
156
|
+
const feeResult: Failable<number, QuoteError> =
|
|
157
|
+
Math.random() > 0.5
|
|
158
|
+
? success(25)
|
|
159
|
+
: failure({ code: 'pricing_unavailable' });
|
|
105
160
|
|
|
106
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
console.log(requiredPort);
|
|
167
|
+
throwIfError(feeResult);
|
|
168
|
+
console.log(feeCents, status, feeResult.data);
|
|
111
169
|
```
|
|
112
170
|
|
|
113
|
-
|
|
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 {
|
|
179
|
+
import {
|
|
180
|
+
failable,
|
|
181
|
+
run,
|
|
182
|
+
success,
|
|
183
|
+
type Failable,
|
|
184
|
+
} from '@pvorona/failable';
|
|
117
185
|
|
|
118
|
-
|
|
119
|
-
console.log('Reading fallback port from disk');
|
|
120
|
-
return 3000;
|
|
121
|
-
}
|
|
186
|
+
type SubmitTransferError = Error;
|
|
122
187
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
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
|
-
`
|
|
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
|
-
|
|
224
|
+
Pass a promise directly when you want rejection capture:
|
|
137
225
|
|
|
138
226
|
```ts
|
|
139
|
-
|
|
227
|
+
const responseResult = await failable(fetch(url));
|
|
228
|
+
```
|
|
140
229
|
|
|
141
|
-
|
|
142
|
-
? success(3000)
|
|
143
|
-
: failure('Missing port');
|
|
230
|
+
`failable(...)` can:
|
|
144
231
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
-
|
|
238
|
+
By default, the thrown or rejected value becomes `.error` unchanged.
|
|
152
239
|
|
|
153
|
-
|
|
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
|
-
|
|
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
|
-
|
|
246
|
+
Use `run(...)` when each step already returns `Failable` and you want to write
|
|
247
|
+
the success path once:
|
|
162
248
|
|
|
163
|
-
|
|
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
|
|
174
|
-
|
|
254
|
+
type Account = {
|
|
255
|
+
id: string;
|
|
256
|
+
balanceCents: number;
|
|
175
257
|
};
|
|
176
258
|
|
|
177
|
-
|
|
178
|
-
|
|
259
|
+
type TransferPlanningError =
|
|
260
|
+
| TransferError
|
|
261
|
+
| { code: 'source_account_not_found'; accountId: string }
|
|
262
|
+
| { code: 'destination_account_not_found'; accountId: string };
|
|
179
263
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
} else {
|
|
188
|
-
const portResult = readPort(configResult.data.port);
|
|
271
|
+
return success({ id: 'checking', balanceCents: 5_000 });
|
|
272
|
+
}
|
|
189
273
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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
|
-
|
|
201
|
-
|
|
290
|
+
return success(undefined);
|
|
291
|
+
}
|
|
202
292
|
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
305
|
+
return success(source);
|
|
306
|
+
}
|
|
208
307
|
|
|
209
|
-
|
|
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
|
-
|
|
314
|
+
const destination = readDestinationAccount(request.toAccountId);
|
|
315
|
+
if (destination.isFailure) return destination;
|
|
212
316
|
|
|
213
|
-
|
|
214
|
-
|
|
317
|
+
const differentAccounts = ensureDifferentAccounts(source.data, destination.data);
|
|
318
|
+
if (differentAccounts.isFailure) return differentAccounts;
|
|
215
319
|
|
|
216
|
-
|
|
217
|
-
if (
|
|
320
|
+
const fundedSource = ensureSufficientFunds(source.data, request.amountCents);
|
|
321
|
+
if (fundedSource.isFailure) return fundedSource;
|
|
218
322
|
|
|
219
|
-
return success(
|
|
323
|
+
return success({ ...request, feeCents: 25 });
|
|
220
324
|
}
|
|
325
|
+
```
|
|
221
326
|
|
|
222
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
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
|
-
|
|
242
|
-
|
|
351
|
+
type TransferAsyncError =
|
|
352
|
+
| TransferPlanningError
|
|
353
|
+
| { code: 'daily_limit_exceeded'; remainingCents: number };
|
|
243
354
|
|
|
244
|
-
|
|
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
|
-
|
|
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
|
|
256
|
-
const
|
|
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(
|
|
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
|
-
|
|
384
|
+
Keep these rules in mind:
|
|
269
385
|
|
|
270
|
-
-
|
|
271
|
-
-
|
|
272
|
-
- `get(...)
|
|
273
|
-
-
|
|
274
|
-
-
|
|
275
|
-
|
|
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
|
-
|
|
393
|
+
## Transport And Runtime Validation
|
|
280
394
|
|
|
281
|
-
|
|
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 {
|
|
285
|
-
|
|
286
|
-
|
|
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 =
|
|
324
|
-
|
|
325
|
-
|
|
406
|
+
const result = planTransfer(
|
|
407
|
+
{
|
|
408
|
+
fromAccountId: 'checking',
|
|
409
|
+
toAccountId: 'savings',
|
|
410
|
+
amountCents: 2_500,
|
|
326
411
|
},
|
|
327
|
-
|
|
412
|
+
5_000,
|
|
328
413
|
);
|
|
329
414
|
|
|
330
|
-
|
|
331
|
-
|
|
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
|
-
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
368
|
-
import { createFailable, toFailableLike } from '@pvorona/failable';
|
|
425
|
+
const candidate: unknown = maybeFromAnotherModule();
|
|
369
426
|
|
|
370
|
-
|
|
371
|
-
|
|
427
|
+
if (isFailable(candidate) && candidate.isFailure) {
|
|
428
|
+
console.error(candidate.error);
|
|
429
|
+
}
|
|
372
430
|
```
|
|
373
431
|
|
|
374
|
-
`
|
|
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
|
|
380
|
-
- `type
|
|
381
|
-
- `
|
|
382
|
-
- `
|
|
383
|
-
|
|
384
|
-
- `
|
|
385
|
-
|
|
386
|
-
- `
|
|
387
|
-
- `
|
|
388
|
-
- `
|
|
389
|
-
- `
|
|
390
|
-
|
|
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
|