@pvorona/failable 0.6.2 → 0.6.3
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 +100 -272
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@ input, missing config, not found, or a dependency call that can fail. Return a
|
|
|
8
8
|
|
|
9
9
|
A `Failable<T, E>` is either `Success<T>` or `Failure<E>`.
|
|
10
10
|
|
|
11
|
-
- `success(
|
|
11
|
+
- `success()` / `success(data)` / `failure()` / `failure(error)` create results
|
|
12
12
|
- `failable(...)` captures thrown or rejected boundaries
|
|
13
13
|
- `run(...)` composes multiple `Failable` steps
|
|
14
14
|
|
|
@@ -20,98 +20,45 @@ npm i @pvorona/failable
|
|
|
20
20
|
|
|
21
21
|
This package is ESM-only and requires Node 18+.
|
|
22
22
|
|
|
23
|
-
## Migration Note
|
|
24
|
-
|
|
25
|
-
If you are upgrading from the previous API name:
|
|
26
|
-
|
|
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
|
|
32
|
-
|
|
33
23
|
## Basic Usage
|
|
34
24
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
not model for you:
|
|
25
|
+
Return `success(...)` or `failure(...)`, then branch on `result.isFailure`.
|
|
26
|
+
The typed error lets the caller decide what to do for each failure reason.
|
|
38
27
|
|
|
39
28
|
```ts
|
|
40
29
|
import { failure, success, type Failable } from '@pvorona/failable';
|
|
41
30
|
|
|
42
|
-
type
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
amountCents: number;
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
type TransferPlan = TransferRequest & {
|
|
49
|
-
feeCents: number;
|
|
50
|
-
};
|
|
51
|
-
|
|
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
|
-
}
|
|
31
|
+
type ReadPortError =
|
|
32
|
+
| { code: 'missing' }
|
|
33
|
+
| { code: 'invalid'; raw: string };
|
|
68
34
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
35
|
+
function readPort(raw: string | undefined): Failable<number, ReadPortError> {
|
|
36
|
+
if (raw === undefined) return failure({ code: 'missing' });
|
|
72
37
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
balanceCents,
|
|
77
|
-
attemptedCents: request.amountCents,
|
|
78
|
-
});
|
|
38
|
+
const port = Number(raw);
|
|
39
|
+
if (!Number.isInteger(port) || port <= 0) {
|
|
40
|
+
return failure({ code: 'invalid', raw });
|
|
79
41
|
}
|
|
80
42
|
|
|
81
|
-
return success(
|
|
43
|
+
return success(port);
|
|
82
44
|
}
|
|
83
45
|
|
|
84
|
-
const result =
|
|
85
|
-
{
|
|
86
|
-
fromAccountId: 'checking',
|
|
87
|
-
toAccountId: 'savings',
|
|
88
|
-
amountCents: 10_000,
|
|
89
|
-
},
|
|
90
|
-
4_500,
|
|
91
|
-
);
|
|
46
|
+
const result = readPort(process.env.PORT);
|
|
92
47
|
|
|
93
48
|
if (result.isFailure) {
|
|
94
49
|
switch (result.error.code) {
|
|
95
|
-
case '
|
|
96
|
-
console.error('
|
|
97
|
-
break;
|
|
98
|
-
case 'amount_too_small':
|
|
99
|
-
console.error(`Transfers start at ${result.error.minAmountCents} cents`);
|
|
50
|
+
case 'missing':
|
|
51
|
+
console.error('PORT is not set');
|
|
100
52
|
break;
|
|
101
|
-
case '
|
|
102
|
-
console.error(
|
|
103
|
-
`Balance ${result.error.balanceCents} is below ${result.error.attemptedCents}`
|
|
104
|
-
);
|
|
53
|
+
case 'invalid':
|
|
54
|
+
console.error(`PORT is not a valid number: ${result.error.raw}`);
|
|
105
55
|
break;
|
|
106
56
|
}
|
|
107
57
|
} else {
|
|
108
|
-
console.log(result.data
|
|
58
|
+
console.log(`Listening on ${result.data}`);
|
|
109
59
|
}
|
|
110
60
|
```
|
|
111
61
|
|
|
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.
|
|
114
|
-
|
|
115
62
|
## Choose The Right API
|
|
116
63
|
|
|
117
64
|
| Need | Use |
|
|
@@ -129,7 +76,7 @@ carries both the success value and the expected failure reason.
|
|
|
129
76
|
## Unwrapping And Recovery
|
|
130
77
|
|
|
131
78
|
Start with ordinary branching on `result.isFailure` or `result.isSuccess`. When
|
|
132
|
-
you
|
|
79
|
+
you want something shorter, use the helper that matches the job:
|
|
133
80
|
|
|
134
81
|
- `result.getOr(fallback)`: return the success value or an eager fallback
|
|
135
82
|
- `result.getOrElse(() => fallback)`: same, but lazily
|
|
@@ -141,81 +88,66 @@ you need a shorter form, use the helper that matches the job:
|
|
|
141
88
|
|
|
142
89
|
Use the lazy forms when the fallback is expensive or has side effects.
|
|
143
90
|
|
|
144
|
-
|
|
145
|
-
import { failure, success, throwIfError } from '@pvorona/failable';
|
|
91
|
+
Using `readPort` from above:
|
|
146
92
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
? success(25)
|
|
150
|
-
: failure({ code: 'pricing_unavailable' as const });
|
|
93
|
+
```ts
|
|
94
|
+
const result = readPort(process.env.PORT);
|
|
151
95
|
|
|
152
|
-
const
|
|
153
|
-
const
|
|
154
|
-
(
|
|
155
|
-
(
|
|
96
|
+
const port = result.getOr(3000);
|
|
97
|
+
const label = result.match(
|
|
98
|
+
(port) => `Listening on ${port}`,
|
|
99
|
+
(error) => `Using default port (${error.code})`
|
|
156
100
|
);
|
|
157
|
-
|
|
158
|
-
throwIfError(feeResult);
|
|
159
|
-
console.log(feeCents, status, feeResult.data);
|
|
160
101
|
```
|
|
161
102
|
|
|
162
|
-
|
|
103
|
+
`throwIfError` narrows the result to `Success` in place, so
|
|
104
|
+
subsequent code can access `.data` without branching:
|
|
163
105
|
|
|
164
|
-
|
|
165
|
-
|
|
106
|
+
```ts
|
|
107
|
+
import { throwIfError } from '@pvorona/failable';
|
|
166
108
|
|
|
167
|
-
|
|
109
|
+
const result = readPort(process.env.PORT);
|
|
168
110
|
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
run,
|
|
173
|
-
success,
|
|
174
|
-
type Failable,
|
|
175
|
-
} from '@pvorona/failable';
|
|
111
|
+
throwIfError(result);
|
|
112
|
+
console.log(result.data * 2);
|
|
113
|
+
```
|
|
176
114
|
|
|
177
|
-
|
|
115
|
+
## Capture Thrown Or Rejected Failures With `failable(...)`
|
|
178
116
|
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
const request = (async () => {
|
|
183
|
-
if (plan.amountCents > 5_000) {
|
|
184
|
-
throw { code: 'ledger_unavailable' } as const;
|
|
185
|
-
}
|
|
117
|
+
Use `failable(...)` at a boundary you do not control. It turns a thrown or
|
|
118
|
+
rejected value into `Failure`, so the rest of your code can stay in normal
|
|
119
|
+
`Failable` flow.
|
|
186
120
|
|
|
187
|
-
|
|
188
|
-
})();
|
|
121
|
+
Use the callback form for synchronous code that can throw:
|
|
189
122
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
return new Error('Ledger unavailable', { cause: error });
|
|
193
|
-
},
|
|
194
|
-
});
|
|
195
|
-
}
|
|
123
|
+
```ts
|
|
124
|
+
import { failable, NormalizedErrors } from '@pvorona/failable';
|
|
196
125
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
): Promise<Failable<{ transferId: string }, SubmitTransferError>> {
|
|
200
|
-
return await run(async function* ({ get }) {
|
|
201
|
-
const created = yield* get(postToLedger(plan));
|
|
126
|
+
const rawConfig = '{"theme":"dark"}';
|
|
127
|
+
const configResult = failable(() => JSON.parse(rawConfig), NormalizedErrors);
|
|
202
128
|
|
|
203
|
-
|
|
204
|
-
|
|
129
|
+
if (configResult.isFailure) {
|
|
130
|
+
console.error(configResult.error.message);
|
|
131
|
+
} else {
|
|
132
|
+
console.log(configResult.data);
|
|
205
133
|
}
|
|
206
134
|
```
|
|
207
135
|
|
|
208
|
-
`
|
|
209
|
-
`
|
|
210
|
-
and expose one stable `Error` shape to the rest of the app. If you only need
|
|
211
|
-
generic `Error` normalization, `NormalizedErrors` is the built-in shortcut.
|
|
212
|
-
Once that helper already returns `Failable`, `submitTransfer(...)` can use
|
|
213
|
-
`run(...)` to compose it like any other step.
|
|
136
|
+
`NormalizedErrors` is the built-in shortcut when you want `.error` to be an
|
|
137
|
+
`Error`.
|
|
214
138
|
|
|
215
139
|
Pass a promise directly when you want rejection capture:
|
|
216
140
|
|
|
217
141
|
```ts
|
|
218
|
-
|
|
142
|
+
import { failable, NormalizedErrors } from '@pvorona/failable';
|
|
143
|
+
import { readFile } from 'node:fs/promises';
|
|
144
|
+
|
|
145
|
+
const fileResult = await failable(
|
|
146
|
+
readFile('config.json', 'utf8'),
|
|
147
|
+
NormalizedErrors
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const config = fileResult.getOr('{}');
|
|
219
151
|
```
|
|
220
152
|
|
|
221
153
|
`failable(...)` can:
|
|
@@ -235,174 +167,76 @@ you to pass the promise directly instead.
|
|
|
235
167
|
## Compose Existing `Failable` Steps With `run(...)`
|
|
236
168
|
|
|
237
169
|
Use `run(...)` when each step already returns `Failable` and you want to write
|
|
238
|
-
the success path once
|
|
170
|
+
the success path once. If any yielded step fails, `run(...)` returns that same
|
|
171
|
+
failure unchanged.
|
|
239
172
|
|
|
240
|
-
Without `run(...)`,
|
|
173
|
+
Without `run(...)`, composing steps means checking each result before
|
|
174
|
+
continuing:
|
|
241
175
|
|
|
242
176
|
```ts
|
|
243
177
|
import { failure, success, type Failable } from '@pvorona/failable';
|
|
244
178
|
|
|
245
|
-
type
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
};
|
|
249
|
-
|
|
250
|
-
type TransferPlanningError =
|
|
251
|
-
| TransferError
|
|
252
|
-
| { code: 'source_account_not_found'; accountId: string }
|
|
253
|
-
| { code: 'destination_account_not_found'; accountId: string };
|
|
254
|
-
|
|
255
|
-
function readSourceAccount(
|
|
256
|
-
accountId: string,
|
|
257
|
-
): Failable<Account, TransferPlanningError> {
|
|
258
|
-
if (accountId !== 'checking') {
|
|
259
|
-
return failure({ code: 'source_account_not_found', accountId });
|
|
260
|
-
}
|
|
179
|
+
type ConfigError =
|
|
180
|
+
| { code: 'missing'; key: string }
|
|
181
|
+
| { code: 'invalid'; key: string; raw: string };
|
|
261
182
|
|
|
262
|
-
|
|
263
|
-
|
|
183
|
+
function readEnv(
|
|
184
|
+
key: string,
|
|
185
|
+
env: Record<string, string | undefined>,
|
|
186
|
+
): Failable<string, ConfigError> {
|
|
187
|
+
const raw = env[key];
|
|
188
|
+
if (raw === undefined) return failure({ code: 'missing', key });
|
|
264
189
|
|
|
265
|
-
|
|
266
|
-
accountId: string,
|
|
267
|
-
): Failable<Account, TransferPlanningError> {
|
|
268
|
-
if (accountId !== 'savings') {
|
|
269
|
-
return failure({ code: 'destination_account_not_found', accountId });
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
return success({ id: 'savings', balanceCents: 20_000 });
|
|
190
|
+
return success(raw);
|
|
273
191
|
}
|
|
274
192
|
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
): Failable<Account, TransferPlanningError> {
|
|
280
|
-
if (source.balanceCents < amountCents) {
|
|
281
|
-
return failure({
|
|
282
|
-
code: 'insufficient_funds',
|
|
283
|
-
balanceCents: source.balanceCents,
|
|
284
|
-
attemptedCents: amountCents,
|
|
285
|
-
});
|
|
193
|
+
function parsePort(raw: string): Failable<number, ConfigError> {
|
|
194
|
+
const port = Number(raw);
|
|
195
|
+
if (!Number.isInteger(port) || port <= 0) {
|
|
196
|
+
return failure({ code: 'invalid', key: 'PORT', raw });
|
|
286
197
|
}
|
|
287
198
|
|
|
288
|
-
return success(
|
|
199
|
+
return success(port);
|
|
289
200
|
}
|
|
290
201
|
|
|
291
|
-
function
|
|
292
|
-
|
|
293
|
-
): Failable<
|
|
294
|
-
const
|
|
202
|
+
function loadConfig(
|
|
203
|
+
env: Record<string, string | undefined>,
|
|
204
|
+
): Failable<{ host: string; port: number }, ConfigError> {
|
|
205
|
+
const hostResult = readEnv('HOST', env);
|
|
206
|
+
if (hostResult.isFailure) return hostResult;
|
|
295
207
|
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
if (source.balanceCents < amountCents) {
|
|
301
|
-
return failure({
|
|
302
|
-
code: 'insufficient_funds',
|
|
303
|
-
balanceCents: source.balanceCents,
|
|
304
|
-
attemptedCents: amountCents,
|
|
305
|
-
});
|
|
306
|
-
}
|
|
208
|
+
const rawPortResult = readEnv('PORT', env);
|
|
209
|
+
if (rawPortResult.isFailure) return rawPortResult;
|
|
307
210
|
|
|
308
|
-
const
|
|
211
|
+
const portResult = parsePort(rawPortResult.data);
|
|
212
|
+
if (portResult.isFailure) return portResult;
|
|
309
213
|
|
|
310
|
-
|
|
311
|
-
return destination;
|
|
312
|
-
}
|
|
313
|
-
|
|
314
|
-
if (source.id === destination.id) {
|
|
315
|
-
return failure({ code: 'same_account' });
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
return success({ fromAccountId, toAccountId, amountCents, feeCents: 25 });
|
|
214
|
+
return success({ host: hostResult.data, port: portResult.data });
|
|
319
215
|
}
|
|
320
216
|
```
|
|
321
217
|
|
|
322
|
-
With
|
|
218
|
+
With `run(...)`, the same flow stays linear:
|
|
323
219
|
|
|
324
220
|
```ts
|
|
325
|
-
import {
|
|
221
|
+
import { run, success, type Failable } from '@pvorona/failable';
|
|
326
222
|
|
|
327
|
-
function
|
|
328
|
-
|
|
329
|
-
): Failable<
|
|
223
|
+
function loadConfig(
|
|
224
|
+
env: Record<string, string | undefined>,
|
|
225
|
+
): Failable<{ host: string; port: number }, ConfigError> {
|
|
330
226
|
return run(function* ({ get }) {
|
|
331
|
-
const
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
return failure({
|
|
335
|
-
code: 'insufficient_funds',
|
|
336
|
-
balanceCents: source.balanceCents,
|
|
337
|
-
attemptedCents: amountCents,
|
|
338
|
-
});
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
const destination = yield* get(readDestinationAccount(toAccountId));
|
|
342
|
-
|
|
343
|
-
if (source.id === destination.id) {
|
|
344
|
-
return failure({ code: 'same_account' });
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
return success({ fromAccountId, toAccountId, amountCents, feeCents: 25 });
|
|
348
|
-
});
|
|
349
|
-
}
|
|
350
|
-
```
|
|
351
|
-
|
|
352
|
-
With one async rule added, the shape stays the same:
|
|
227
|
+
const host = yield* get(readEnv('HOST', env));
|
|
228
|
+
const rawPort = yield* get(readEnv('PORT', env));
|
|
229
|
+
const port = yield* get(parsePort(rawPort));
|
|
353
230
|
|
|
354
|
-
|
|
355
|
-
import { failure, run, success, type Failable } from '@pvorona/failable';
|
|
356
|
-
|
|
357
|
-
type TransferAsyncError =
|
|
358
|
-
| TransferPlanningError
|
|
359
|
-
| { code: 'daily_limit_exceeded'; remainingCents: number };
|
|
360
|
-
|
|
361
|
-
async function planTransfer(
|
|
362
|
-
{ fromAccountId, toAccountId, amountCents }: TransferRequest,
|
|
363
|
-
): Promise<Failable<TransferPlan, TransferAsyncError>> {
|
|
364
|
-
return await run(async function* ({ get }) {
|
|
365
|
-
const source = yield* get(readSourceAccount(fromAccountId));
|
|
366
|
-
|
|
367
|
-
if (source.balanceCents < amountCents) {
|
|
368
|
-
return failure({
|
|
369
|
-
code: 'insufficient_funds',
|
|
370
|
-
balanceCents: source.balanceCents,
|
|
371
|
-
attemptedCents: amountCents,
|
|
372
|
-
});
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
const destination = yield* get(readDestinationAccount(toAccountId));
|
|
376
|
-
|
|
377
|
-
if (source.id === destination.id) {
|
|
378
|
-
return failure({ code: 'same_account' });
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
// Simulate an async step that can fail
|
|
382
|
-
yield* get(
|
|
383
|
-
(async () => {
|
|
384
|
-
const remainingCents = source.id === 'checking' ? 3_000 : 0;
|
|
385
|
-
if (amountCents > remainingCents) {
|
|
386
|
-
return failure({ code: 'daily_limit_exceeded', remainingCents });
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
return success();
|
|
390
|
-
})()
|
|
391
|
-
);
|
|
392
|
-
|
|
393
|
-
return success({ fromAccountId, toAccountId, amountCents, feeCents: 25 });
|
|
231
|
+
return success({ host, port });
|
|
394
232
|
});
|
|
395
233
|
}
|
|
396
234
|
```
|
|
397
235
|
|
|
398
|
-
Keep these rules in mind:
|
|
399
|
-
|
|
400
|
-
- `run(...)` composes existing `Failable` values
|
|
401
236
|
- if a yielded step fails, `run(...)` returns that original failure unchanged
|
|
402
237
|
- in async builders, keep using `yield* get(...)`; do not write `await get(...)`
|
|
403
|
-
- `run(...)` does not capture thrown values or rejected promises into `Failure
|
|
404
|
-
|
|
405
|
-
enter `run(...)`
|
|
238
|
+
- `run(...)` does not capture thrown values or rejected promises into `Failure`;
|
|
239
|
+
wrap throwing boundaries with `failable(...)` before they enter `run(...)`
|
|
406
240
|
|
|
407
241
|
## Transport And Runtime Validation
|
|
408
242
|
|
|
@@ -413,18 +247,12 @@ side:
|
|
|
413
247
|
|
|
414
248
|
```ts
|
|
415
249
|
import {
|
|
250
|
+
failure,
|
|
416
251
|
failable,
|
|
417
252
|
toFailableLike,
|
|
418
253
|
} from '@pvorona/failable';
|
|
419
254
|
|
|
420
|
-
const result =
|
|
421
|
-
{
|
|
422
|
-
fromAccountId: 'checking',
|
|
423
|
-
toAccountId: 'savings',
|
|
424
|
-
amountCents: 2_500,
|
|
425
|
-
},
|
|
426
|
-
5_000,
|
|
427
|
-
);
|
|
255
|
+
const result = failure({ code: 'missing' as const });
|
|
428
256
|
|
|
429
257
|
const wire = toFailableLike(result);
|
|
430
258
|
const hydrated = failable(wire);
|