@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.
Files changed (2) hide show
  1. package/README.md +100 -272
  2. 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(...)` / `failure()` / `failure(...)` create results
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
- 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:
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 TransferRequest = {
43
- fromAccountId: string;
44
- toAccountId: string;
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
- if (request.amountCents < 100) {
70
- return failure({ code: 'amount_too_small', minAmountCents: 100 });
71
- }
35
+ function readPort(raw: string | undefined): Failable<number, ReadPortError> {
36
+ if (raw === undefined) return failure({ code: 'missing' });
72
37
 
73
- if (balanceCents < request.amountCents) {
74
- return failure({
75
- code: 'insufficient_funds',
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({ ...request, feeCents: 25 });
43
+ return success(port);
82
44
  }
83
45
 
84
- const result = planTransfer(
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 '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`);
50
+ case 'missing':
51
+ console.error('PORT is not set');
100
52
  break;
101
- case 'insufficient_funds':
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.feeCents);
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 need a shorter form, use the helper that matches the job:
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
- ```ts
145
- import { failure, success, throwIfError } from '@pvorona/failable';
91
+ Using `readPort` from above:
146
92
 
147
- const feeResult =
148
- Math.random() > 0.5
149
- ? success(25)
150
- : failure({ code: 'pricing_unavailable' as const });
93
+ ```ts
94
+ const result = readPort(process.env.PORT);
151
95
 
152
- const feeCents = feeResult.getOr(25);
153
- const status = feeResult.match(
154
- (value) => `Fee is ${value} cents`,
155
- ({ code }) => `Cannot quote fee: ${code}`
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
- ## Capture Thrown Or Rejected Failures With `failable(...)`
103
+ `throwIfError` narrows the result to `Success` in place, so
104
+ subsequent code can access `.data` without branching:
163
105
 
164
- Use `failable(...)` at boundaries that throw or reject, then normalize
165
- that failure once at the boundary if needed:
106
+ ```ts
107
+ import { throwIfError } from '@pvorona/failable';
166
108
 
167
- Using `TransferPlan` from above:
109
+ const result = readPort(process.env.PORT);
168
110
 
169
- ```ts
170
- import {
171
- failable,
172
- run,
173
- success,
174
- type Failable,
175
- } from '@pvorona/failable';
111
+ throwIfError(result);
112
+ console.log(result.data * 2);
113
+ ```
176
114
 
177
- type SubmitTransferError = Error;
115
+ ## Capture Thrown Or Rejected Failures With `failable(...)`
178
116
 
179
- async function postToLedger(
180
- plan: TransferPlan,
181
- ): Promise<Failable<{ transferId: string }, SubmitTransferError>> {
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
- return { transferId: 'tr_123' };
188
- })();
121
+ Use the callback form for synchronous code that can throw:
189
122
 
190
- return await failable(request, {
191
- normalizeError(error) {
192
- return new Error('Ledger unavailable', { cause: error });
193
- },
194
- });
195
- }
123
+ ```ts
124
+ import { failable, NormalizedErrors } from '@pvorona/failable';
196
125
 
197
- async function submitTransfer(
198
- plan: TransferPlan,
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
- return success(created);
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
- `postToLedger(...)` is the boundary adapter. It uses
209
- `failable(..., { normalizeError })` to capture a raw throw/rejection once
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
- const responseResult = await failable(fetch(url));
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(...)`, composition often becomes an error ladder:
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 Account = {
246
- id: string;
247
- balanceCents: number;
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
- return success({ id: 'checking', balanceCents: 5_000 });
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
- function readDestinationAccount(
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
- function ensureSufficientFunds(
277
- source: Account,
278
- amountCents: number,
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(source);
199
+ return success(port);
289
200
  }
290
201
 
291
- function planTransfer(
292
- { fromAccountId, toAccountId, amountCents }: TransferRequest,
293
- ): Failable<TransferPlan, TransferPlanningError> {
294
- const source = readSourceAccount(fromAccountId);
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
- if (source.isFailure) {
297
- return source;
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 destination = readDestinationAccount(toAccountId);
211
+ const portResult = parsePort(rawPortResult.data);
212
+ if (portResult.isFailure) return portResult;
309
213
 
310
- if (destination.isFailure) {
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 the same helpers, `run(...)` keeps the flow linear:
218
+ With `run(...)`, the same flow stays linear:
323
219
 
324
220
  ```ts
325
- import { failure, run, success, type Failable } from '@pvorona/failable';
221
+ import { run, success, type Failable } from '@pvorona/failable';
326
222
 
327
- function planTransfer(
328
- { fromAccountId, toAccountId, amountCents }: TransferRequest,
329
- ): Failable<TransferPlan, TransferPlanningError> {
223
+ function loadConfig(
224
+ env: Record<string, string | undefined>,
225
+ ): Failable<{ host: string; port: number }, ConfigError> {
330
226
  return run(function* ({ get }) {
331
- const source = yield* get(readSourceAccount(fromAccountId));
332
-
333
- if (source.balanceCents < amountCents) {
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
- ```ts
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
- - wrap throwing or rejecting boundaries with `failable(...)` before they
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 = planTransfer(
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pvorona/failable",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
4
4
  "description": "Typed success/failure results for expected failures in TypeScript.",
5
5
  "keywords": [
6
6
  "failable",