@backendkit-labs/result 0.1.2 → 0.1.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 (3) hide show
  1. package/LICENSE +184 -0
  2. package/README.md +1211 -1152
  3. package/package.json +62 -36
package/README.md CHANGED
@@ -1,1152 +1,1211 @@
1
- # @backendkit-labs/result
2
-
3
- [![npm version](https://img.shields.io/npm/v/@backendkit-labs/result?style=flat-square&color=cb3837)](https://www.npmjs.com/package/@backendkit-labs/result)
4
- [![CI](https://img.shields.io/github/actions/workflow/status/backendkit-dev/backendkit-monorepo/ci.yml?style=flat-square&label=CI)](https://github.com/backendkit-dev/backendkit-monorepo/actions/workflows/ci.yml)
5
- [![License](https://img.shields.io/npm/l/@backendkit-labs/result?style=flat-square)](LICENSE)
6
- [![Node](https://img.shields.io/node/v/@backendkit-labs/result?style=flat-square)](package.json)
7
-
8
- > Type-safe Result monad for Node.js. Generic error types, observability, resilience, and optional NestJS integration. Zero runtime dependencies.
9
-
10
- Replaces `try/catch` with an explicit, composable type that makes errors visible in the type system. Every operation either succeeds (`ok`) or fails (`fail`) — and the TypeScript compiler enforces that you handle both.
11
-
12
- ---
13
-
14
- ## Table of Contents
15
-
16
- - [Installation](#installation)
17
- - [Quick Start](#quick-start)
18
- - [Core Concepts](#core-concepts)
19
- - [Constructors](#constructors)
20
- - [Type Guards](#type-guards)
21
- - [Transformations](#transformations)
22
- - [Pattern Matching](#pattern-matching)
23
- - [Side Effects](#side-effects)
24
- - [Unwrapping](#unwrapping)
25
- - [Conversion](#conversion)
26
- - [Execution — run & track](#execution--run--track)
27
- - [Resilience](#resilience)
28
- - [Combinators](#combinators)
29
- - [Flow — Fluent Pipeline](#flow--fluent-pipeline)
30
- - [NestJS Integration](#nestjs-integration)
31
- - [Architecture](#architecture)
32
-
33
- ---
34
-
35
- ## Installation
36
-
37
- ```bash
38
- npm install @backendkit-labs/result
39
- ```
40
-
41
- NestJS peer dependencies (only for the `/nestjs` subpath):
42
-
43
- ```bash
44
- npm install @nestjs/common @nestjs/core rxjs
45
- ```
46
-
47
- ---
48
-
49
- ## Quick Start
50
-
51
- ```typescript
52
- import { ok, fail, run, isOk, isFail, match } from '@backendkit-labs/result';
53
-
54
- // Wrap a throwable async call
55
- const result = await run(() => fetchUser(userId));
56
-
57
- // Handle both branches
58
- const message = match(result, {
59
- ok: (user) => `Welcome, ${user.name}`,
60
- fail: (error) => `Error: ${error.message}`,
61
- });
62
-
63
- // Or guard and narrow
64
- if (isOk(result)) {
65
- console.log(result.value.email); // TypeScript knows value exists
66
- }
67
- if (isFail(result)) {
68
- console.error(result.error); // TypeScript knows error exists
69
- }
70
- ```
71
-
72
- ---
73
-
74
- ## Core Concepts
75
-
76
- ### The Result type
77
-
78
- ```typescript
79
- type Result<T, E = Error> =
80
- | { readonly ok: true; readonly value: T }
81
- | { readonly ok: false; readonly error: E }
82
- ```
83
-
84
- A discriminated union either a success with a `value` of type `T`, or a failure with an `error` of type `E`. Both branches are explicit in the type, so TypeScript will not let you access `value` without first confirming `ok === true`.
85
-
86
- The error type `E` is fully generic. You can use anything: `Error`, `string`, a union of domain error types, or an enum.
87
-
88
- ```typescript
89
- // Typed errors as a discriminated union
90
- type UserError =
91
- | { code: 'NOT_FOUND'; id: string }
92
- | { code: 'FORBIDDEN' }
93
- | { code: 'DB_ERROR'; cause: Error }
94
-
95
- async function findUser(id: string): Promise<Result<User, UserError>> { ... }
96
- ```
97
-
98
- ### RichResult — with observability
99
-
100
- ```typescript
101
- type RichResult<T, E = Error> = Result<T, E> & {
102
- readonly durationMs: number // execution time in ms
103
- readonly timestamp: string // ISO 8601 start time
104
- readonly operation?: string // logical name
105
- readonly correlationId?: string // trace/request ID
106
- readonly tags?: string[] // categorization labels
107
- }
108
- ```
109
-
110
- Produced by `track()`. Carries the same `ok / value / error` shape as a plain `Result` plus timing and metadata — ready for logging, metrics dashboards, or distributed tracing.
111
-
112
- ---
113
-
114
- ## Constructors
115
-
116
- ### `ok(value)`
117
-
118
- Creates a successful result.
119
-
120
- ```typescript
121
- import { ok } from '@backendkit-labs/result';
122
-
123
- const r = ok(42); // Result<number, never>
124
- const r = ok({ id: 1 }); // Result<{ id: number }, never>
125
- const r = ok(undefined); // Result<undefined, never>
126
- ```
127
-
128
- ### `fail(error)`
129
-
130
- Creates a failed result.
131
-
132
- ```typescript
133
- import { fail } from '@backendkit-labs/result';
134
-
135
- const r = fail(new Error('network error')); // Result<never, Error>
136
- const r = fail('not-found'); // Result<never, string>
137
- const r = fail({ code: 'FORBIDDEN' }); // Result<never, { code: string }>
138
- ```
139
-
140
- ### `fromThrowable(fn, errorTransform?)`
141
-
142
- Wraps a synchronous function that might throw. Catches any exception and converts it to a `fail`.
143
-
144
- ```typescript
145
- import { fromThrowable } from '@backendkit-labs/result';
146
-
147
- // Without transform — caught value is cast to E
148
- const parsed = fromThrowable(() => JSON.parse(raw));
149
- // Result<unknown, Error>
150
-
151
- // With transform convert the caught value to your domain error
152
- const parsed = fromThrowable<Config, string>(
153
- () => JSON.parse(raw),
154
- (e) => `Invalid config: ${(e as SyntaxError).message}`,
155
- );
156
- // Result<Config, string>
157
-
158
- // Practical: reading a file
159
- const content = fromThrowable(
160
- () => fs.readFileSync('./config.json', 'utf-8'),
161
- (e) => new ConfigError('Could not read config file', { cause: e }),
162
- );
163
- ```
164
-
165
- ### `fromPromise(promise, errorTransform?)`
166
-
167
- Converts a Promise to a `Promise<Result<T, E>>`, catching rejections.
168
-
169
- ```typescript
170
- import { fromPromise } from '@backendkit-labs/result';
171
-
172
- // Wrap any existing promise
173
- const result = await fromPromise(fetch(url).then(r => r.json()));
174
-
175
- // With error transform
176
- const result = await fromPromise(
177
- db.users.findOrThrow(id),
178
- (e) => e instanceof PrismaError && e.code === 'P2025'
179
- ? { code: 'NOT_FOUND' as const, id }
180
- : { code: 'DB_ERROR' as const, cause: e as Error },
181
- );
182
- // Result<User, { code: 'NOT_FOUND'; id: string } | { code: 'DB_ERROR'; cause: Error }>
183
- ```
184
-
185
- ### `fromNullable(value, errorOnNull)`
186
-
187
- Converts a nullable value to a Result. Returns `ok(value)` when non-null/undefined, `fail(error)` otherwise.
188
-
189
- ```typescript
190
- import { fromNullable } from '@backendkit-labs/result';
191
-
192
- const user = cache.get(userId); // User | undefined
193
-
194
- const result = fromNullable(user, { code: 'CACHE_MISS' as const });
195
- // Result<User, { code: 'CACHE_MISS' }>
196
-
197
- // Chaining fromNullable in a pipeline
198
- const result = fromNullable(
199
- config.database?.host,
200
- new ConfigError('database.host is required'),
201
- );
202
- ```
203
-
204
- ---
205
-
206
- ## Type Guards
207
-
208
- ### `isOk(result)` / `isFail(result)`
209
-
210
- Narrow the type to the success or failure branch. After the guard, TypeScript knows the exact shape.
211
-
212
- ```typescript
213
- import { isOk, isFail } from '@backendkit-labs/result';
214
-
215
- const result: Result<User, UserError> = await findUser(id);
216
-
217
- if (isOk(result)) {
218
- result.value.email; // TypeScript: value is User
219
- }
220
-
221
- if (isFail(result)) {
222
- result.error.code; // ✓ TypeScript: error is UserError
223
- }
224
-
225
- // Useful in array filters
226
- const users = results.filter(isOk).map(r => r.value);
227
- ```
228
-
229
- ### `isRich(result)`
230
-
231
- Returns `true` if the result was produced by `track()` and carries observability metadata.
232
-
233
- ```typescript
234
- import { isRich } from '@backendkit-labs/result';
235
-
236
- const result = await track(() => fetchUser(id));
237
- if (isRich(result)) {
238
- console.log(`Took ${result.durationMs}ms`);
239
- }
240
- ```
241
-
242
- ---
243
-
244
- ## Transformations
245
-
246
- All transformations short-circuit on failure they skip the function and pass the `fail` result through unchanged.
247
-
248
- ### `map(result, fn)`
249
-
250
- Transform the success value into a different type.
251
-
252
- ```typescript
253
- import { map } from '@backendkit-labs/result';
254
-
255
- const userResult: Result<User, Error> = await run(() => fetchUser(id));
256
-
257
- const nameResult: Result<string, Error> = map(userResult, user => user.name);
258
-
259
- // Chain multiple maps
260
- const initials = map(
261
- map(nameResult, name => name.split(' ')),
262
- parts => parts.map(p => p[0]).join(''),
263
- );
264
- ```
265
-
266
- ### `mapError(result, fn)`
267
-
268
- Transform the error value without touching the success branch.
269
-
270
- ```typescript
271
- import { mapError } from '@backendkit-labs/result';
272
-
273
- // Convert infrastructure errors to domain errors
274
- const result = mapError(
275
- await fromPromise(db.users.find(id)),
276
- (dbError) => ({
277
- code: 'DB_ERROR' as const,
278
- message: 'Failed to fetch user',
279
- cause: dbError,
280
- }),
281
- );
282
- // Result<User, { code: 'DB_ERROR'; message: string; cause: unknown }>
283
-
284
- // Translate error messages
285
- const localized = mapError(
286
- serviceResult,
287
- (e) => t(`errors.${e.code}`),
288
- );
289
- ```
290
-
291
- ### `flatMap(result, fn)`
292
-
293
- Chain a Result-returning function. The failure from either the original result or the chained function short-circuits the pipeline.
294
-
295
- ```typescript
296
- import { flatMap, fromNullable } from '@backendkit-labs/result';
297
-
298
- const orderResult = flatMap(
299
- await run(() => fetchUser(userId)),
300
- (user) => fromNullable(user.activeOrder, { code: 'NO_ACTIVE_ORDER' as const }),
301
- );
302
- // Result<Order, Error | { code: 'NO_ACTIVE_ORDER' }>
303
- ```
304
-
305
- ### `flatMapAsync(result, fn)`
306
-
307
- Async version of `flatMap`.
308
-
309
- ```typescript
310
- import { flatMapAsync } from '@backendkit-labs/result';
311
-
312
- const profileResult = await flatMapAsync(
313
- await run(() => fetchUser(userId)),
314
- async (user) => run(() => fetchProfile(user.profileId)),
315
- );
316
- // Result<Profile, Error>
317
- ```
318
-
319
- ### `mapAsync(result, fn)`
320
-
321
- Maps the success value with an async function.
322
-
323
- ```typescript
324
- import { mapAsync } from '@backendkit-labs/result';
325
-
326
- const enriched = await mapAsync(
327
- userResult,
328
- async (user) => ({ ...user, permissions: await loadPermissions(user.id) }),
329
- );
330
- // Result<User & { permissions: string[] }, Error>
331
- ```
332
-
333
- ---
334
-
335
- ## Pattern Matching
336
-
337
- ### `match(result, handlers)` / `fold(result, handlers)`
338
-
339
- Exhaustive pattern match — the compiler ensures both branches are handled. `fold` is an alias.
340
-
341
- ```typescript
342
- import { match } from '@backendkit-labs/result';
343
-
344
- const response = match(result, {
345
- ok: (user) => ({ status: 200, body: user }),
346
- fail: (error) => ({ status: error.code === 'NOT_FOUND' ? 404 : 500, body: error }),
347
- });
348
-
349
- // Returning different types from each branch
350
- const display = match(paymentResult, {
351
- ok: (payment) => `Payment of $${payment.amount} confirmed`,
352
- fail: (error) => `Payment failed: ${error.message}`,
353
- });
354
-
355
- // Logging pattern
356
- match(result, {
357
- ok: (data) => logger.info('Operation succeeded', { data }),
358
- fail: (error) => logger.error('Operation failed', { error }),
359
- });
360
- ```
361
-
362
- ---
363
-
364
- ## Side Effects
365
-
366
- ### `tap(result, fn)` / `tapError(result, fn)`
367
-
368
- Run a side effect without altering the result. Returns the original result unchanged — useful for logging in the middle of a pipeline.
369
-
370
- ```typescript
371
- import { tap, tapError } from '@backendkit-labs/result';
372
-
373
- const result = tap(
374
- await run(() => processPayment(dto)),
375
- (payment) => {
376
- analytics.track('payment.processed', { amount: payment.amount });
377
- logger.info('Payment processed', payment);
378
- },
379
- );
380
- // result is still Result<Payment, Error>
381
-
382
- // Log errors without breaking the chain
383
- const result = tapError(
384
- await run(() => fetchInventory(sku)),
385
- (error) => logger.warn('Inventory fetch failed', { sku, error }),
386
- );
387
-
388
- // Combined
389
- const result = tap(
390
- tapError(
391
- await run(() => fetchUser(id)),
392
- (e) => logger.error('User fetch failed', e),
393
- ),
394
- (user) => cache.set(id, user),
395
- );
396
- ```
397
-
398
- ---
399
-
400
- ## Unwrapping
401
-
402
- Use these when you need to extract the raw value — typically at the edge of your application (controller, CLI output, test assertions).
403
-
404
- ### `unwrap(result)` throws on failure
405
-
406
- ```typescript
407
- import { unwrap } from '@backendkit-labs/result';
408
-
409
- const user = unwrap(userResult); // throws if fail
410
- ```
411
-
412
- ### `unwrapOr(result, default)` — safe fallback
413
-
414
- ```typescript
415
- import { unwrapOr } from '@backendkit-labs/result';
416
-
417
- const user = unwrapOr(userResult, defaultUser);
418
- const count = unwrapOr(countResult, 0);
419
- const items = unwrapOr(listResult, []);
420
- ```
421
-
422
- ### `unwrapOrElse(result, fn)` — computed fallback
423
-
424
- ```typescript
425
- import { unwrapOrElse } from '@backendkit-labs/result';
426
-
427
- const user = unwrapOrElse(
428
- userResult,
429
- (error) => error.code === 'NOT_FOUND' ? guestUser : throw error,
430
- );
431
- ```
432
-
433
- ### `unwrapError(result)` extract the error
434
-
435
- ```typescript
436
- import { unwrapError } from '@backendkit-labs/result';
437
-
438
- const error = unwrapError(failResult); // throws if ok
439
- ```
440
-
441
- ### `expect(result, message)` custom error message
442
-
443
- ```typescript
444
- import { expect as resultExpect } from '@backendkit-labs/result';
445
-
446
- const config = resultExpect(
447
- fromThrowable(() => loadConfig()),
448
- 'Failed to load configuration — cannot start server',
449
- );
450
- ```
451
-
452
- ---
453
-
454
- ## Conversion
455
-
456
- ### `toPromise(result)`
457
-
458
- ```typescript
459
- import { toPromise } from '@backendkit-labs/result';
460
-
461
- // Bridges Result-based code with Promise-based APIs
462
- const user = await toPromise(userResult); // rejects if fail
463
- ```
464
-
465
- ### `toNullable(result)` / `toUndefined(result)`
466
-
467
- ```typescript
468
- import { toNullable, toUndefined } from '@backendkit-labs/result';
469
-
470
- const user: User | null = toNullable(userResult);
471
- const user: User | undefined = toUndefined(userResult);
472
-
473
- // Useful with optional chaining
474
- const name = toNullable(userResult)?.name ?? 'Anonymous';
475
- ```
476
-
477
- ---
478
-
479
- ## Execution — `run` & `track`
480
-
481
- ### `run(fn, errorTransform?)`
482
-
483
- Executes any async (or sync) function and captures thrown exceptions as `fail`. The cleanest way to integrate with existing throw-based code.
484
-
485
- ```typescript
486
- import { run } from '@backendkit-labs/result';
487
-
488
- // Wraps any async call
489
- const result = await run(() => fetch(url).then(r => r.json()));
490
-
491
- // With error classification
492
- const result = await run<User, UserError>(
493
- () => db.users.findOrThrow(id),
494
- (e) => e instanceof NotFoundError
495
- ? { code: 'NOT_FOUND' as const, id }
496
- : { code: 'DB_ERROR' as const, cause: e as Error },
497
- );
498
-
499
- // Sync functions work too
500
- const result = await run(() => JSON.parse(raw));
501
- ```
502
-
503
- ### `track(fn, options?)`
504
-
505
- Like `run()` but also measures execution time and attaches metadata. Returns a `RichResult<T, E>`.
506
-
507
- ```typescript
508
- import { track } from '@backendkit-labs/result';
509
-
510
- const result = await track(
511
- () => db.users.findOrThrow(id),
512
- {
513
- operation: 'user.find',
514
- correlationId: request.headers['x-correlation-id'],
515
- tags: ['db', 'users'],
516
- },
517
- );
518
-
519
- if (result.ok) {
520
- logger.info('User fetched', {
521
- operation: result.operation, // 'user.find'
522
- durationMs: result.durationMs, // e.g. 12
523
- timestamp: result.timestamp, // '2026-05-13T...'
524
- tags: result.tags, // ['db', 'users']
525
- });
526
- }
527
- ```
528
-
529
- ### `enrich(result, options?)` / `simplify(richResult)`
530
-
531
- Promote a plain `Result` to `RichResult`, or strip metadata back to a plain `Result`.
532
-
533
- ```typescript
534
- import { enrich, simplify } from '@backendkit-labs/result';
535
-
536
- // Attach metadata to an existing result
537
- const rich = enrich(ok(user), {
538
- operation: 'cache.hit',
539
- correlationId: reqId,
540
- });
541
- // RichResult<User, never>
542
-
543
- // Strip metadata when you no longer need it
544
- const plain = simplify(richResult);
545
- // Result<User, Error>
546
- ```
547
-
548
- ---
549
-
550
- ## Resilience
551
-
552
- ### `retry(fn, options)`
553
-
554
- Retries a Result-returning async function on failure.
555
-
556
- ```typescript
557
- import { retry, run } from '@backendkit-labs/result';
558
-
559
- // Basic retry
560
- const result = await retry(
561
- () => run(() => callExternalApi()),
562
- { attempts: 3 },
563
- );
564
-
565
- // With delay between attempts
566
- const result = await retry(
567
- () => run(() => sendEmail(payload)),
568
- { attempts: 5, delayMs: 1_000 },
569
- );
570
-
571
- // Stop retrying on specific errors
572
- const result = await retry(
573
- () => run(() => callApi(), classifyError),
574
- {
575
- attempts: 4,
576
- delayMs: 500,
577
- shouldRetry: (error, attempt) => {
578
- console.log(`Attempt ${attempt} failed:`, error);
579
- return error.code !== 'UNAUTHORIZED'; // don't retry 401
580
- },
581
- onRetry: (error, attempt) => {
582
- metrics.increment('api.retry', { attempt });
583
- },
584
- },
585
- );
586
- ```
587
-
588
- ### `retryWithBackoff(fn, options)`
589
-
590
- Exponential backoff: delay doubles on each retry, capped at `maxDelayMs`. Supports **jitter** to prevent thundering herd when multiple instances retry simultaneously.
591
-
592
- ```typescript
593
- import { retryWithBackoff, run } from '@backendkit-labs/result';
594
-
595
- // 100ms 200ms 400ms → 800ms (capped at 1000ms)
596
- const result = await retryWithBackoff(
597
- () => run(() => fetchWithFlakeyNetwork()),
598
- {
599
- attempts: 5,
600
- delayMs: 100, // initial delay
601
- maxDelayMs: 1_000, // cap
602
- shouldRetry: (error) => error.retryable === true,
603
- },
604
- );
605
-
606
- // Database deadlock retry pattern
607
- const result = await retryWithBackoff(
608
- () => run(() => db.transaction(fn), classifyDbError),
609
- {
610
- attempts: 3,
611
- delayMs: 50,
612
- maxDelayMs: 500,
613
- shouldRetry: (e) => e.code === 'DEADLOCK',
614
- onRetry: (e, n) => logger.warn(`Deadlock retry #${n}`, e),
615
- },
616
- );
617
- ```
618
-
619
- #### Jitter
620
-
621
- When many instances of your service fail at the same time (e.g. a downstream goes down), they all retry on the same schedule — creating a synchronized spike that can overwhelm the recovering service. Jitter spreads those retries across time.
622
-
623
- ```typescript
624
- // Full jitter — delay = random(0, computedDelay)
625
- // Maximum spread. Best for high-concurrency scenarios (many parallel clients).
626
- await retryWithBackoff(() => run(() => callApi()), {
627
- attempts: 4,
628
- delayMs: 500,
629
- maxDelayMs: 10_000,
630
- jitter: true,
631
- });
632
-
633
- // Partial jitter — delay ± (computedDelay × factor)
634
- // Keeps delays close to the backoff curve while adding noise.
635
- // 0.25 = ±25%: a computed 1000ms delay becomes 750ms–1250ms.
636
- await retryWithBackoff(() => run(() => callApi()), {
637
- attempts: 4,
638
- delayMs: 500,
639
- maxDelayMs: 10_000,
640
- jitter: 0.25,
641
- });
642
- ```
643
-
644
- | `jitter` value | Behaviour | Use when |
645
- |---|---|---|
646
- | `false` / omitted | No randomness — deterministic delays | Tests, single-instance services |
647
- | `true` | Full jitter: `random(0, delay)` | Many parallel clients retrying the same service |
648
- | `0.0–1.0` | Partial jitter: `delay ± (delay × factor)` | You want backoff shape preserved with light noise |
649
-
650
- ### `withTimeout(fn, ms, timeoutError)`
651
-
652
- Races a Result-returning function against a deadline.
653
-
654
- ```typescript
655
- import { withTimeout, run } from '@backendkit-labs/result';
656
-
657
- // Enforce SLA on external calls
658
- const result = await withTimeout(
659
- () => run(() => callSlowApi()),
660
- 5_000,
661
- new TimeoutError('API call exceeded 5s SLA'),
662
- );
663
-
664
- // With typed error
665
- const result = await withTimeout<Report, ApiError>(
666
- () => run(() => generateReport(params), toApiError),
667
- 30_000,
668
- { code: 'TIMEOUT', message: 'Report generation timed out' },
669
- );
670
-
671
- if (isFail(result) && result.error.code === 'TIMEOUT') {
672
- return servePartialReport();
673
- }
674
- ```
675
-
676
- ### Combining resilience primitives
677
-
678
- ```typescript
679
- // Retry with backoff + timeout on each attempt
680
- const result = await withTimeout(
681
- () => retryWithBackoff(
682
- () => run(() => fetchCriticalData()),
683
- { attempts: 3, delayMs: 100, maxDelayMs: 500 },
684
- ),
685
- 10_000,
686
- new Error('Gave up after 10s'),
687
- );
688
- ```
689
-
690
- ---
691
-
692
- ## Combinators
693
-
694
- ### `all(results)` all must succeed
695
-
696
- Returns `ok([...values])` or the first failure.
697
-
698
- ```typescript
699
- import { all, run } from '@backendkit-labs/result';
700
-
701
- const [userResult, orderResult, inventoryResult] = await Promise.all([
702
- run(() => fetchUser(userId)),
703
- run(() => fetchOrder(orderId)),
704
- run(() => fetchInventory(sku)),
705
- ]);
706
-
707
- const combined = all([userResult, orderResult, inventoryResult]);
708
- // Result<[User, Order, Inventory], Error>
709
-
710
- if (isOk(combined)) {
711
- const [user, order, inventory] = combined.value;
712
- }
713
- ```
714
-
715
- ### `any(operations)` — first success wins
716
-
717
- Tries operations sequentially, returns the first that succeeds.
718
-
719
- ```typescript
720
- import { any, run } from '@backendkit-labs/result';
721
-
722
- // Cache → DB fallback chain
723
- const user = await any([
724
- () => run(() => cache.get(id)),
725
- () => run(() => replicaDb.findUser(id)),
726
- () => run(() => primaryDb.findUser(id)),
727
- ]);
728
- ```
729
-
730
- ### `parallel(operations, options?)` concurrent execution
731
-
732
- Runs all operations concurrently (with optional concurrency limit). Returns all values or the first failure.
733
-
734
- ```typescript
735
- import { parallel, run } from '@backendkit-labs/result';
736
-
737
- // Process all at once
738
- const result = await parallel(
739
- userIds.map(id => () => run(() => fetchUser(id))),
740
- );
741
- // Result<User[], Error>
742
-
743
- // Limit concurrency to avoid overwhelming downstream
744
- const result = await parallel(
745
- imageIds.map(id => () => run(() => processImage(id))),
746
- { concurrency: 5 },
747
- );
748
-
749
- if (isOk(result)) {
750
- const users: User[] = result.value;
751
- }
752
- ```
753
-
754
- ### `partition(results)` — split successes and failures
755
-
756
- ```typescript
757
- import { partition } from '@backendkit-labs/result';
758
-
759
- const results = await Promise.all(ids.map(id => run(() => fetchUser(id))));
760
- const [users, errors] = partition(results);
761
- // users: User[] — all successful values
762
- // errors: Error[] — all failure values
763
-
764
- logger.info(`Fetched ${users.length} users, ${errors.length} failed`);
765
- ```
766
-
767
- ### `collect(results)` success values only
768
-
769
- Like `partition` but silently drops failures.
770
-
771
- ```typescript
772
- import { collect } from '@backendkit-labs/result';
773
-
774
- const results = await Promise.all(ids.map(id => run(() => fetchUser(id))));
775
- const users = collect(results);
776
- // User[] failures are discarded
777
- ```
778
-
779
- ### `traverse(items, fn)` map array through a Result function
780
-
781
- Applies a Result-returning function to each item. Succeeds only if all items succeed (short-circuits on the first failure).
782
-
783
- ```typescript
784
- import { traverse, fromNullable } from '@backendkit-labs/result';
785
-
786
- // Validate every item in an array
787
- const result = traverse(
788
- requestBody.items,
789
- (item) => fromNullable(
790
- catalog.get(item.sku),
791
- { code: 'SKU_NOT_FOUND' as const, sku: item.sku },
792
- ),
793
- );
794
- // Result<CatalogItem[], { code: 'SKU_NOT_FOUND'; sku: string }>
795
-
796
- // Parse and validate a list of inputs
797
- const result = traverse(
798
- rawIds,
799
- (id) => id.match(/^\d+$/)
800
- ? ok(parseInt(id, 10))
801
- : fail(`Invalid ID format: ${id}`),
802
- );
803
- ```
804
-
805
- ### `combine2(r1, r2)` / `combine3(r1, r2, r3)` — typed tuples
806
-
807
- Combines two or three results into a precisely typed tuple. Short-circuits on the first failure.
808
-
809
- ```typescript
810
- import { combine2, combine3, run } from '@backendkit-labs/result';
811
-
812
- const result = combine2(
813
- await run(() => fetchUser(userId)),
814
- await run(() => fetchAccount(accountId)),
815
- );
816
- // Result<[User, Account], Error>
817
-
818
- if (isOk(result)) {
819
- const [user, account] = result.value; // fully typed
820
- }
821
-
822
- // Three results
823
- const result = combine3(
824
- await run(() => fetchUser(userId)),
825
- await run(() => fetchPermissions(userId)),
826
- await run(() => fetchSettings(userId)),
827
- );
828
- // Result<[User, Permission[], Settings], Error>
829
- ```
830
-
831
- ---
832
-
833
- ## Flow Fluent Pipeline
834
-
835
- `Flow<T, E>` is a composable wrapper that lets you build transformation pipelines. Each step is skipped if the result is already a failure.
836
-
837
- ### Starting a pipeline
838
-
839
- ```typescript
840
- import { Flow, ok, fail } from '@backendkit-labs/result';
841
-
842
- // From an existing result
843
- const flow = Flow.from(ok(42));
844
- Flow.from(await run(() => fetchUser(id)));
845
-
846
- // Empty pipeline (value is void)
847
- Flow.start().map(() => loadConfig());
848
- ```
849
-
850
- ### `.map(fn)` / `.mapError(fn)`
851
-
852
- ```typescript
853
- const result = Flow.from(await run(() => fetchUser(id)))
854
- .map(user => user.profile)
855
- .map(profile => profile.avatar ?? defaultAvatar)
856
- .getResult();
857
- // Result<string, Error>
858
-
859
- // Transform errors along the way
860
- const result = Flow.from(await run(() => callExternalApi(), toRawError))
861
- .mapError(raw => new DomainError(raw.message, raw.code))
862
- .getResult();
863
- ```
864
-
865
- ### `.flatMap(fn)`
866
-
867
- ```typescript
868
- const orderResult = Flow.from(await run(() => fetchUser(userId)))
869
- .flatMap(user =>
870
- user.activeOrderId
871
- ? ok(user.activeOrderId)
872
- : fail(new Error('No active order')),
873
- )
874
- .flatMap(orderId => fromNullable(ordersCache.get(orderId), new Error('Cache miss')))
875
- .getResult();
876
- ```
877
-
878
- ### `.filter(predicate, error)`
879
-
880
- ```typescript
881
- const result = Flow.from(ok(age))
882
- .filter(a => a >= 18, new Error('Must be 18 or older'))
883
- .filter(a => a <= 120, new Error('Age value is unrealistic'))
884
- .map(a => categorizeAge(a))
885
- .getResult();
886
- ```
887
-
888
- ### `.tap(fn)` / `.tapError(fn)`
889
-
890
- ```typescript
891
- const result = Flow.from(await run(() => processPayment(dto)))
892
- .tap(payment => analytics.track('payment.success', payment))
893
- .tap(payment => cache.invalidate(`balance:${payment.userId}`))
894
- .tapError(err => logger.error('Payment failed', err))
895
- .tapError(err => metrics.increment('payment.failure'))
896
- .getResult();
897
- ```
898
-
899
- ### `.recover(fn)`
900
-
901
- Convert a failure into a success — useful for providing defaults.
902
-
903
- ```typescript
904
- const result = Flow.from(await run(() => fetchFromPrimary(key)))
905
- .recover(error => {
906
- logger.warn('Primary failed, using default', error);
907
- return defaultValue;
908
- })
909
- .getResult();
910
- // Result<T, never> — failure branch is eliminated
911
- ```
912
-
913
- ### `.match(handlers)`
914
-
915
- Terminate the pipeline with an exhaustive match.
916
-
917
- ```typescript
918
- const httpResponse = Flow.from(await run(() => processRequest(req)))
919
- .map(data => ({ status: 200, body: data }))
920
- .match({
921
- ok: (response) => response,
922
- fail: (error) => ({ status: 500, body: { message: error.message } }),
923
- });
924
- ```
925
-
926
- ### Full pipeline example
927
-
928
- ```typescript
929
- const response = await Flow.from(
930
- await track(
931
- () => db.users.findOrThrow(userId),
932
- { operation: 'user.fetch', tags: ['db'] },
933
- ),
934
- )
935
- .tapError(e => logger.error('User not found', e))
936
- .flatMap(user =>
937
- user.isActive
938
- ? ok(user)
939
- : fail(new ForbiddenError('Account suspended')),
940
- )
941
- .map(user => ({
942
- id: user.id,
943
- name: user.name,
944
- email: user.email,
945
- }))
946
- .tap(dto => cache.set(`user:${userId}`, dto, { ttl: 60 }))
947
- .match({
948
- ok: (dto) => ({ statusCode: 200, data: dto }),
949
- fail: (error) => ({
950
- statusCode: error instanceof ForbiddenError ? 403 : 404,
951
- message: error.message,
952
- }),
953
- });
954
- ```
955
-
956
- ---
957
-
958
- ## NestJS Integration
959
-
960
- Import from the `/nestjs` subpath.
961
-
962
- ```typescript
963
- import { ResultModule } from '@backendkit-labs/result/nestjs';
964
-
965
- @Module({ imports: [ResultModule] })
966
- export class AppModule {}
967
- ```
968
-
969
- ### `@AsResult(operation?)`wrap method in `run()`
970
-
971
- Any exception thrown inside the method becomes a `fail`. The return type becomes `Promise<Result<T, E>>`.
972
-
973
- ```typescript
974
- import { AsResult } from '@backendkit-labs/result/nestjs';
975
- import { ok, fail, isOk } from '@backendkit-labs/result';
976
-
977
- @Injectable()
978
- export class UserService {
979
- @AsResult('user.find')
980
- async findOne(id: string): Promise<User> {
981
- return this.db.users.findOrThrow(id); // throws becomes fail()
982
- }
983
- }
984
-
985
- // In the controller
986
- const result = await this.userService.findOne(id);
987
- // Result<User, Error>
988
- if (isOk(result)) {
989
- return result.value;
990
- }
991
- ```
992
-
993
- ### `@WithMetrics(options?)` — wrap method in `track()`
994
-
995
- Like `@AsResult()` but returns a `RichResult` with timing and metadata.
996
-
997
- ```typescript
998
- import { WithMetrics } from '@backendkit-labs/result/nestjs';
999
- import { isOk } from '@backendkit-labs/result';
1000
-
1001
- @Injectable()
1002
- export class PaymentService {
1003
- @WithMetrics({ operation: 'payment.charge', tags: ['stripe'] })
1004
- async charge(dto: ChargeDto): Promise<Payment> {
1005
- return this.stripeClient.charges.create({
1006
- amount: dto.amount,
1007
- currency: dto.currency,
1008
- });
1009
- }
1010
- }
1011
-
1012
- // In the controller
1013
- const result = await this.paymentService.charge(dto);
1014
- // RichResult<Payment, Error>
1015
-
1016
- logger.info('Charge result', {
1017
- ok: result.ok,
1018
- operation: result.operation, // 'payment.charge'
1019
- durationMs: result.durationMs, // e.g. 340
1020
- tags: result.tags, // ['stripe']
1021
- });
1022
- ```
1023
-
1024
- ### `ResultInterceptor` HTTP response normalization
1025
-
1026
- Automatically converts `Result` and `RichResult` return values from controller methods into a consistent JSON response shape.
1027
-
1028
- ```typescript
1029
- import { ResultInterceptor } from '@backendkit-labs/result/nestjs';
1030
-
1031
- // Global — applies to every controller
1032
- app.useGlobalInterceptors(app.get(ResultInterceptor));
1033
-
1034
- // Or per-controller / per-route
1035
- @UseInterceptors(ResultInterceptor)
1036
- @Controller('users')
1037
- export class UsersController { ... }
1038
- ```
1039
-
1040
- **Response shape for plain `Result`:**
1041
-
1042
- ```json
1043
- // Ok
1044
- { "ok": true, "data": { "id": 1, "name": "Alice" } }
1045
-
1046
- // Fail
1047
- { "ok": false, "error": "User not found" }
1048
- ```
1049
-
1050
- **Response shape for `RichResult`:**
1051
-
1052
- ```json
1053
- // Ok
1054
- {
1055
- "ok": true,
1056
- "data": { "id": 1, "name": "Alice" },
1057
- "meta": {
1058
- "operation": "user.find",
1059
- "durationMs": 12,
1060
- "timestamp": "2026-05-13T20:00:00.000Z",
1061
- "correlationId": "req-abc-123",
1062
- "tags": ["db", "users"]
1063
- }
1064
- }
1065
-
1066
- // Fail
1067
- {
1068
- "ok": false,
1069
- "error": "User not found",
1070
- "meta": {
1071
- "operation": "user.find",
1072
- "durationMs": 3,
1073
- "timestamp": "2026-05-13T20:00:00.001Z"
1074
- }
1075
- }
1076
- ```
1077
-
1078
- Non-Result return values (plain objects, arrays, primitives) pass through unchanged.
1079
-
1080
- ### Full NestJS controller example
1081
-
1082
- ```typescript
1083
- import { Controller, Get, Post, Param, Body, UseInterceptors } from '@nestjs/common';
1084
- import { ResultInterceptor } from '@backendkit-labs/result/nestjs';
1085
- import { ok, fail, run, match, isOk } from '@backendkit-labs/result';
1086
-
1087
- @UseInterceptors(ResultInterceptor)
1088
- @Controller('payments')
1089
- export class PaymentsController {
1090
- constructor(private readonly paymentService: PaymentService) {}
1091
-
1092
- @Post()
1093
- async charge(@Body() dto: ChargeDto) {
1094
- // RichResult normalized automatically by ResultInterceptor
1095
- return this.paymentService.charge(dto);
1096
- }
1097
-
1098
- @Get(':id')
1099
- async findOne(@Param('id') id: string) {
1100
- const result = await this.paymentService.findOne(id);
1101
-
1102
- // Handle 404 before returning — interceptor normalizes the rest
1103
- return match(result, {
1104
- ok: (payment) => ok(payment),
1105
- fail: (error) => error.code === 'NOT_FOUND'
1106
- ? fail(`Payment ${id} not found`)
1107
- : fail('Internal error'),
1108
- });
1109
- }
1110
- }
1111
- ```
1112
-
1113
- ---
1114
-
1115
- ## Architecture
1116
-
1117
- ```
1118
- @backendkit-labs/result (core — zero runtime dependencies)
1119
- Result<T, E> discriminated union, fully generic error type
1120
- RichResult<T, E> Result + durationMs, timestamp, operation, tags
1121
- ok() / fail() constructors
1122
- fromThrowable() / fromPromise() exception capture
1123
- fromNullable() null/undefined coercion
1124
- isOk() / isFail() / isRich() type guards
1125
- map() / mapError() / flatMap() transformations
1126
- match() / fold() pattern matching
1127
- tap() / tapError() side effects
1128
- unwrap() / unwrapOr() / expect() unwrapping
1129
- toPromise() / toNullable() conversion
1130
- run() / track() async execution with error capture
1131
- enrich() / simplify() RichResult promotion / demotion
1132
- retry() / retryWithBackoff() resilience — retries
1133
- withTimeout() resilience — deadline enforcement
1134
- all() / any() / parallel() combinators — multiple results
1135
- partition() / collect() / traverse() combinators — array operations
1136
- combine2() / combine3() combinators — typed tuples
1137
- Flow<T, E> fluent pipeline builder
1138
-
1139
- @backendkit-labs/result/nestjs (optional NestJS layer)
1140
- @AsResult() method decorator → run()
1141
- @WithMetrics() method decorator → track()
1142
- ResultInterceptor HTTP response normalization
1143
- ResultModule NestJS module
1144
- ```
1145
-
1146
- The core is a pure TypeScript library with no runtime dependencies. The NestJS layer lives in a separate subpath export (`/nestjs`) and is tree-shaken from the core bundle.
1147
-
1148
- ---
1149
-
1150
- ## License
1151
-
1152
- MIT [BackendKit Labs](https://github.com/backendkit-dev)
1
+ # @backendkit-labs/result
2
+
3
+ [![npm version](https://img.shields.io/npm/v/@backendkit-labs/result?style=flat-square&color=cb3837)](https://www.npmjs.com/package/@backendkit-labs/result)
4
+ [![CI](https://img.shields.io/github/actions/workflow/status/backendkit-dev/backendkit-monorepo/ci.yml?style=flat-square&label=CI)](https://github.com/backendkit-dev/backendkit-monorepo/actions/workflows/ci.yml)
5
+ [![License](https://img.shields.io/npm/l/@backendkit-labs/result?style=flat-square)](LICENSE)
6
+ [![Node](https://img.shields.io/node/v/@backendkit-labs/result?style=flat-square)](package.json)
7
+
8
+ > Type-safe Result monad for Node.js. Generic error types, observability, resilience, and optional NestJS integration. Zero runtime dependencies.
9
+
10
+ Replaces `try/catch` with an explicit, composable type that makes errors visible in the type system. Every operation either succeeds (`ok`) or fails (`fail`) — and the TypeScript compiler enforces that you handle both.
11
+
12
+ ---
13
+
14
+ ## Table of Contents
15
+
16
+ - [Installation](#installation)
17
+ - [Quick Start](#quick-start)
18
+ - [Core Concepts](#core-concepts)
19
+ - [Constructors](#constructors)
20
+ - [Type Guards](#type-guards)
21
+ - [Transformations](#transformations)
22
+ - [Pattern Matching](#pattern-matching)
23
+ - [Side Effects](#side-effects)
24
+ - [Unwrapping](#unwrapping)
25
+ - [Conversion](#conversion)
26
+ - [Execution — run & track](#execution--run--track)
27
+ - [Resilience](#resilience)
28
+ - [Combinators](#combinators)
29
+ - [Flow — Fluent Pipeline](#flow--fluent-pipeline)
30
+ - [NestJS Integration](#nestjs-integration)
31
+ - [Architecture](#architecture)
32
+
33
+ ---
34
+
35
+ ## Installation
36
+
37
+ ```bash
38
+ npm install @backendkit-labs/result
39
+ ```
40
+
41
+ NestJS peer dependencies (only for the `/nestjs` subpath):
42
+
43
+ ```bash
44
+ npm install @nestjs/common @nestjs/core rxjs
45
+ ```
46
+
47
+ ---
48
+
49
+ ## TypeScript Configuration
50
+
51
+ ### Subpath exports (`/nestjs`)
52
+
53
+ This package uses the `exports` field in `package.json` to expose the `/nestjs` subpath. TypeScript's ability to resolve it depends on the `moduleResolution` setting in your `tsconfig.json`.
54
+
55
+ **Modern resolution (recommended) no extra config needed:**
56
+
57
+ ```json
58
+ {
59
+ "compilerOptions": {
60
+ "moduleResolution": "bundler"
61
+ }
62
+ }
63
+ ```
64
+
65
+ `"bundler"`, `"node16"`, and `"nodenext"` all understand the `exports` field natively. This is the recommended setting for any project using a bundler or NestJS on TypeScript 5.
66
+
67
+ **Legacy resolution (`"node"`) — add a `paths` alias:**
68
+
69
+ NestJS projects generated before ~2024 default to `"moduleResolution": "node"`, which ignores the `exports` field. Add an explicit alias so TypeScript can find the types:
70
+
71
+ ```json
72
+ {
73
+ "compilerOptions": {
74
+ "moduleResolution": "node",
75
+ "paths": {
76
+ "@backendkit-labs/result/nestjs": [
77
+ "./node_modules/@backendkit-labs/result/dist/nestjs/index"
78
+ ]
79
+ }
80
+ }
81
+ }
82
+ ```
83
+
84
+ > **Why?** The `"node"` resolver was designed before subpath exports existed and only reads `main`/`types` at the package root it ignores the `exports` map entirely. The `paths` alias manually points TypeScript to the correct `.d.ts` file.
85
+
86
+ ### NestJS decorator support
87
+
88
+ ```json
89
+ {
90
+ "compilerOptions": {
91
+ "experimentalDecorators": true,
92
+ "emitDecoratorMetadata": true
93
+ }
94
+ }
95
+ ```
96
+
97
+ And import `reflect-metadata` once at application startup:
98
+
99
+ ```typescript
100
+ // main.ts
101
+ import 'reflect-metadata';
102
+ ```
103
+
104
+ > NestJS CLI scaffolds these automatically. You only need to verify them if setting up a project manually.
105
+
106
+ ---
107
+
108
+ ## Quick Start
109
+
110
+ ```typescript
111
+ import { ok, fail, run, isOk, isFail, match } from '@backendkit-labs/result';
112
+
113
+ // Wrap a throwable async call
114
+ const result = await run(() => fetchUser(userId));
115
+
116
+ // Handle both branches
117
+ const message = match(result, {
118
+ ok: (user) => `Welcome, ${user.name}`,
119
+ fail: (error) => `Error: ${error.message}`,
120
+ });
121
+
122
+ // Or guard and narrow
123
+ if (isOk(result)) {
124
+ console.log(result.value.email); // TypeScript knows value exists
125
+ }
126
+ if (isFail(result)) {
127
+ console.error(result.error); // TypeScript knows error exists
128
+ }
129
+ ```
130
+
131
+ ---
132
+
133
+ ## Core Concepts
134
+
135
+ ### The Result type
136
+
137
+ ```typescript
138
+ type Result<T, E = Error> =
139
+ | { readonly ok: true; readonly value: T }
140
+ | { readonly ok: false; readonly error: E }
141
+ ```
142
+
143
+ A discriminated union — either a success with a `value` of type `T`, or a failure with an `error` of type `E`. Both branches are explicit in the type, so TypeScript will not let you access `value` without first confirming `ok === true`.
144
+
145
+ The error type `E` is fully generic. You can use anything: `Error`, `string`, a union of domain error types, or an enum.
146
+
147
+ ```typescript
148
+ // Typed errors as a discriminated union
149
+ type UserError =
150
+ | { code: 'NOT_FOUND'; id: string }
151
+ | { code: 'FORBIDDEN' }
152
+ | { code: 'DB_ERROR'; cause: Error }
153
+
154
+ async function findUser(id: string): Promise<Result<User, UserError>> { ... }
155
+ ```
156
+
157
+ ### RichResult — with observability
158
+
159
+ ```typescript
160
+ type RichResult<T, E = Error> = Result<T, E> & {
161
+ readonly durationMs: number // execution time in ms
162
+ readonly timestamp: string // ISO 8601 start time
163
+ readonly operation?: string // logical name
164
+ readonly correlationId?: string // trace/request ID
165
+ readonly tags?: string[] // categorization labels
166
+ }
167
+ ```
168
+
169
+ Produced by `track()`. Carries the same `ok / value / error` shape as a plain `Result` plus timing and metadata — ready for logging, metrics dashboards, or distributed tracing.
170
+
171
+ ---
172
+
173
+ ## Constructors
174
+
175
+ ### `ok(value)`
176
+
177
+ Creates a successful result.
178
+
179
+ ```typescript
180
+ import { ok } from '@backendkit-labs/result';
181
+
182
+ const r = ok(42); // Result<number, never>
183
+ const r = ok({ id: 1 }); // Result<{ id: number }, never>
184
+ const r = ok(undefined); // Result<undefined, never>
185
+ ```
186
+
187
+ ### `fail(error)`
188
+
189
+ Creates a failed result.
190
+
191
+ ```typescript
192
+ import { fail } from '@backendkit-labs/result';
193
+
194
+ const r = fail(new Error('network error')); // Result<never, Error>
195
+ const r = fail('not-found'); // Result<never, string>
196
+ const r = fail({ code: 'FORBIDDEN' }); // Result<never, { code: string }>
197
+ ```
198
+
199
+ ### `fromThrowable(fn, errorTransform?)`
200
+
201
+ Wraps a synchronous function that might throw. Catches any exception and converts it to a `fail`.
202
+
203
+ ```typescript
204
+ import { fromThrowable } from '@backendkit-labs/result';
205
+
206
+ // Without transform — caught value is cast to E
207
+ const parsed = fromThrowable(() => JSON.parse(raw));
208
+ // Result<unknown, Error>
209
+
210
+ // With transform convert the caught value to your domain error
211
+ const parsed = fromThrowable<Config, string>(
212
+ () => JSON.parse(raw),
213
+ (e) => `Invalid config: ${(e as SyntaxError).message}`,
214
+ );
215
+ // Result<Config, string>
216
+
217
+ // Practical: reading a file
218
+ const content = fromThrowable(
219
+ () => fs.readFileSync('./config.json', 'utf-8'),
220
+ (e) => new ConfigError('Could not read config file', { cause: e }),
221
+ );
222
+ ```
223
+
224
+ ### `fromPromise(promise, errorTransform?)`
225
+
226
+ Converts a Promise to a `Promise<Result<T, E>>`, catching rejections.
227
+
228
+ ```typescript
229
+ import { fromPromise } from '@backendkit-labs/result';
230
+
231
+ // Wrap any existing promise
232
+ const result = await fromPromise(fetch(url).then(r => r.json()));
233
+
234
+ // With error transform
235
+ const result = await fromPromise(
236
+ db.users.findOrThrow(id),
237
+ (e) => e instanceof PrismaError && e.code === 'P2025'
238
+ ? { code: 'NOT_FOUND' as const, id }
239
+ : { code: 'DB_ERROR' as const, cause: e as Error },
240
+ );
241
+ // Result<User, { code: 'NOT_FOUND'; id: string } | { code: 'DB_ERROR'; cause: Error }>
242
+ ```
243
+
244
+ ### `fromNullable(value, errorOnNull)`
245
+
246
+ Converts a nullable value to a Result. Returns `ok(value)` when non-null/undefined, `fail(error)` otherwise.
247
+
248
+ ```typescript
249
+ import { fromNullable } from '@backendkit-labs/result';
250
+
251
+ const user = cache.get(userId); // User | undefined
252
+
253
+ const result = fromNullable(user, { code: 'CACHE_MISS' as const });
254
+ // Result<User, { code: 'CACHE_MISS' }>
255
+
256
+ // Chaining fromNullable in a pipeline
257
+ const result = fromNullable(
258
+ config.database?.host,
259
+ new ConfigError('database.host is required'),
260
+ );
261
+ ```
262
+
263
+ ---
264
+
265
+ ## Type Guards
266
+
267
+ ### `isOk(result)` / `isFail(result)`
268
+
269
+ Narrow the type to the success or failure branch. After the guard, TypeScript knows the exact shape.
270
+
271
+ ```typescript
272
+ import { isOk, isFail } from '@backendkit-labs/result';
273
+
274
+ const result: Result<User, UserError> = await findUser(id);
275
+
276
+ if (isOk(result)) {
277
+ result.value.email; // ✓ TypeScript: value is User
278
+ }
279
+
280
+ if (isFail(result)) {
281
+ result.error.code; // ✓ TypeScript: error is UserError
282
+ }
283
+
284
+ // Useful in array filters
285
+ const users = results.filter(isOk).map(r => r.value);
286
+ ```
287
+
288
+ ### `isRich(result)`
289
+
290
+ Returns `true` if the result was produced by `track()` and carries observability metadata.
291
+
292
+ ```typescript
293
+ import { isRich } from '@backendkit-labs/result';
294
+
295
+ const result = await track(() => fetchUser(id));
296
+ if (isRich(result)) {
297
+ console.log(`Took ${result.durationMs}ms`);
298
+ }
299
+ ```
300
+
301
+ ---
302
+
303
+ ## Transformations
304
+
305
+ All transformations short-circuit on failure — they skip the function and pass the `fail` result through unchanged.
306
+
307
+ ### `map(result, fn)`
308
+
309
+ Transform the success value into a different type.
310
+
311
+ ```typescript
312
+ import { map } from '@backendkit-labs/result';
313
+
314
+ const userResult: Result<User, Error> = await run(() => fetchUser(id));
315
+
316
+ const nameResult: Result<string, Error> = map(userResult, user => user.name);
317
+
318
+ // Chain multiple maps
319
+ const initials = map(
320
+ map(nameResult, name => name.split(' ')),
321
+ parts => parts.map(p => p[0]).join(''),
322
+ );
323
+ ```
324
+
325
+ ### `mapError(result, fn)`
326
+
327
+ Transform the error value without touching the success branch.
328
+
329
+ ```typescript
330
+ import { mapError } from '@backendkit-labs/result';
331
+
332
+ // Convert infrastructure errors to domain errors
333
+ const result = mapError(
334
+ await fromPromise(db.users.find(id)),
335
+ (dbError) => ({
336
+ code: 'DB_ERROR' as const,
337
+ message: 'Failed to fetch user',
338
+ cause: dbError,
339
+ }),
340
+ );
341
+ // Result<User, { code: 'DB_ERROR'; message: string; cause: unknown }>
342
+
343
+ // Translate error messages
344
+ const localized = mapError(
345
+ serviceResult,
346
+ (e) => t(`errors.${e.code}`),
347
+ );
348
+ ```
349
+
350
+ ### `flatMap(result, fn)`
351
+
352
+ Chain a Result-returning function. The failure from either the original result or the chained function short-circuits the pipeline.
353
+
354
+ ```typescript
355
+ import { flatMap, fromNullable } from '@backendkit-labs/result';
356
+
357
+ const orderResult = flatMap(
358
+ await run(() => fetchUser(userId)),
359
+ (user) => fromNullable(user.activeOrder, { code: 'NO_ACTIVE_ORDER' as const }),
360
+ );
361
+ // Result<Order, Error | { code: 'NO_ACTIVE_ORDER' }>
362
+ ```
363
+
364
+ ### `flatMapAsync(result, fn)`
365
+
366
+ Async version of `flatMap`.
367
+
368
+ ```typescript
369
+ import { flatMapAsync } from '@backendkit-labs/result';
370
+
371
+ const profileResult = await flatMapAsync(
372
+ await run(() => fetchUser(userId)),
373
+ async (user) => run(() => fetchProfile(user.profileId)),
374
+ );
375
+ // Result<Profile, Error>
376
+ ```
377
+
378
+ ### `mapAsync(result, fn)`
379
+
380
+ Maps the success value with an async function.
381
+
382
+ ```typescript
383
+ import { mapAsync } from '@backendkit-labs/result';
384
+
385
+ const enriched = await mapAsync(
386
+ userResult,
387
+ async (user) => ({ ...user, permissions: await loadPermissions(user.id) }),
388
+ );
389
+ // Result<User & { permissions: string[] }, Error>
390
+ ```
391
+
392
+ ---
393
+
394
+ ## Pattern Matching
395
+
396
+ ### `match(result, handlers)` / `fold(result, handlers)`
397
+
398
+ Exhaustive pattern match — the compiler ensures both branches are handled. `fold` is an alias.
399
+
400
+ ```typescript
401
+ import { match } from '@backendkit-labs/result';
402
+
403
+ const response = match(result, {
404
+ ok: (user) => ({ status: 200, body: user }),
405
+ fail: (error) => ({ status: error.code === 'NOT_FOUND' ? 404 : 500, body: error }),
406
+ });
407
+
408
+ // Returning different types from each branch
409
+ const display = match(paymentResult, {
410
+ ok: (payment) => `Payment of $${payment.amount} confirmed`,
411
+ fail: (error) => `Payment failed: ${error.message}`,
412
+ });
413
+
414
+ // Logging pattern
415
+ match(result, {
416
+ ok: (data) => logger.info('Operation succeeded', { data }),
417
+ fail: (error) => logger.error('Operation failed', { error }),
418
+ });
419
+ ```
420
+
421
+ ---
422
+
423
+ ## Side Effects
424
+
425
+ ### `tap(result, fn)` / `tapError(result, fn)`
426
+
427
+ Run a side effect without altering the result. Returns the original result unchanged — useful for logging in the middle of a pipeline.
428
+
429
+ ```typescript
430
+ import { tap, tapError } from '@backendkit-labs/result';
431
+
432
+ const result = tap(
433
+ await run(() => processPayment(dto)),
434
+ (payment) => {
435
+ analytics.track('payment.processed', { amount: payment.amount });
436
+ logger.info('Payment processed', payment);
437
+ },
438
+ );
439
+ // result is still Result<Payment, Error>
440
+
441
+ // Log errors without breaking the chain
442
+ const result = tapError(
443
+ await run(() => fetchInventory(sku)),
444
+ (error) => logger.warn('Inventory fetch failed', { sku, error }),
445
+ );
446
+
447
+ // Combined
448
+ const result = tap(
449
+ tapError(
450
+ await run(() => fetchUser(id)),
451
+ (e) => logger.error('User fetch failed', e),
452
+ ),
453
+ (user) => cache.set(id, user),
454
+ );
455
+ ```
456
+
457
+ ---
458
+
459
+ ## Unwrapping
460
+
461
+ Use these when you need to extract the raw value — typically at the edge of your application (controller, CLI output, test assertions).
462
+
463
+ ### `unwrap(result)` — throws on failure
464
+
465
+ ```typescript
466
+ import { unwrap } from '@backendkit-labs/result';
467
+
468
+ const user = unwrap(userResult); // throws if fail
469
+ ```
470
+
471
+ ### `unwrapOr(result, default)` safe fallback
472
+
473
+ ```typescript
474
+ import { unwrapOr } from '@backendkit-labs/result';
475
+
476
+ const user = unwrapOr(userResult, defaultUser);
477
+ const count = unwrapOr(countResult, 0);
478
+ const items = unwrapOr(listResult, []);
479
+ ```
480
+
481
+ ### `unwrapOrElse(result, fn)` — computed fallback
482
+
483
+ ```typescript
484
+ import { unwrapOrElse } from '@backendkit-labs/result';
485
+
486
+ const user = unwrapOrElse(
487
+ userResult,
488
+ (error) => error.code === 'NOT_FOUND' ? guestUser : throw error,
489
+ );
490
+ ```
491
+
492
+ ### `unwrapError(result)` extract the error
493
+
494
+ ```typescript
495
+ import { unwrapError } from '@backendkit-labs/result';
496
+
497
+ const error = unwrapError(failResult); // throws if ok
498
+ ```
499
+
500
+ ### `expect(result, message)` custom error message
501
+
502
+ ```typescript
503
+ import { expect as resultExpect } from '@backendkit-labs/result';
504
+
505
+ const config = resultExpect(
506
+ fromThrowable(() => loadConfig()),
507
+ 'Failed to load configuration — cannot start server',
508
+ );
509
+ ```
510
+
511
+ ---
512
+
513
+ ## Conversion
514
+
515
+ ### `toPromise(result)`
516
+
517
+ ```typescript
518
+ import { toPromise } from '@backendkit-labs/result';
519
+
520
+ // Bridges Result-based code with Promise-based APIs
521
+ const user = await toPromise(userResult); // rejects if fail
522
+ ```
523
+
524
+ ### `toNullable(result)` / `toUndefined(result)`
525
+
526
+ ```typescript
527
+ import { toNullable, toUndefined } from '@backendkit-labs/result';
528
+
529
+ const user: User | null = toNullable(userResult);
530
+ const user: User | undefined = toUndefined(userResult);
531
+
532
+ // Useful with optional chaining
533
+ const name = toNullable(userResult)?.name ?? 'Anonymous';
534
+ ```
535
+
536
+ ---
537
+
538
+ ## Execution — `run` & `track`
539
+
540
+ ### `run(fn, errorTransform?)`
541
+
542
+ Executes any async (or sync) function and captures thrown exceptions as `fail`. The cleanest way to integrate with existing throw-based code.
543
+
544
+ ```typescript
545
+ import { run } from '@backendkit-labs/result';
546
+
547
+ // Wraps any async call
548
+ const result = await run(() => fetch(url).then(r => r.json()));
549
+
550
+ // With error classification
551
+ const result = await run<User, UserError>(
552
+ () => db.users.findOrThrow(id),
553
+ (e) => e instanceof NotFoundError
554
+ ? { code: 'NOT_FOUND' as const, id }
555
+ : { code: 'DB_ERROR' as const, cause: e as Error },
556
+ );
557
+
558
+ // Sync functions work too
559
+ const result = await run(() => JSON.parse(raw));
560
+ ```
561
+
562
+ ### `track(fn, options?)`
563
+
564
+ Like `run()` but also measures execution time and attaches metadata. Returns a `RichResult<T, E>`.
565
+
566
+ ```typescript
567
+ import { track } from '@backendkit-labs/result';
568
+
569
+ const result = await track(
570
+ () => db.users.findOrThrow(id),
571
+ {
572
+ operation: 'user.find',
573
+ correlationId: request.headers['x-correlation-id'],
574
+ tags: ['db', 'users'],
575
+ },
576
+ );
577
+
578
+ if (result.ok) {
579
+ logger.info('User fetched', {
580
+ operation: result.operation, // 'user.find'
581
+ durationMs: result.durationMs, // e.g. 12
582
+ timestamp: result.timestamp, // '2026-05-13T...'
583
+ tags: result.tags, // ['db', 'users']
584
+ });
585
+ }
586
+ ```
587
+
588
+ ### `enrich(result, options?)` / `simplify(richResult)`
589
+
590
+ Promote a plain `Result` to `RichResult`, or strip metadata back to a plain `Result`.
591
+
592
+ ```typescript
593
+ import { enrich, simplify } from '@backendkit-labs/result';
594
+
595
+ // Attach metadata to an existing result
596
+ const rich = enrich(ok(user), {
597
+ operation: 'cache.hit',
598
+ correlationId: reqId,
599
+ });
600
+ // RichResult<User, never>
601
+
602
+ // Strip metadata when you no longer need it
603
+ const plain = simplify(richResult);
604
+ // Result<User, Error>
605
+ ```
606
+
607
+ ---
608
+
609
+ ## Resilience
610
+
611
+ ### `retry(fn, options)`
612
+
613
+ Retries a Result-returning async function on failure.
614
+
615
+ ```typescript
616
+ import { retry, run } from '@backendkit-labs/result';
617
+
618
+ // Basic retry
619
+ const result = await retry(
620
+ () => run(() => callExternalApi()),
621
+ { attempts: 3 },
622
+ );
623
+
624
+ // With delay between attempts
625
+ const result = await retry(
626
+ () => run(() => sendEmail(payload)),
627
+ { attempts: 5, delayMs: 1_000 },
628
+ );
629
+
630
+ // Stop retrying on specific errors
631
+ const result = await retry(
632
+ () => run(() => callApi(), classifyError),
633
+ {
634
+ attempts: 4,
635
+ delayMs: 500,
636
+ shouldRetry: (error, attempt) => {
637
+ console.log(`Attempt ${attempt} failed:`, error);
638
+ return error.code !== 'UNAUTHORIZED'; // don't retry 401
639
+ },
640
+ onRetry: (error, attempt) => {
641
+ metrics.increment('api.retry', { attempt });
642
+ },
643
+ },
644
+ );
645
+ ```
646
+
647
+ ### `retryWithBackoff(fn, options)`
648
+
649
+ Exponential backoff: delay doubles on each retry, capped at `maxDelayMs`. Supports **jitter** to prevent thundering herd when multiple instances retry simultaneously.
650
+
651
+ ```typescript
652
+ import { retryWithBackoff, run } from '@backendkit-labs/result';
653
+
654
+ // 100ms → 200ms → 400ms → 800ms (capped at 1000ms)
655
+ const result = await retryWithBackoff(
656
+ () => run(() => fetchWithFlakeyNetwork()),
657
+ {
658
+ attempts: 5,
659
+ delayMs: 100, // initial delay
660
+ maxDelayMs: 1_000, // cap
661
+ shouldRetry: (error) => error.retryable === true,
662
+ },
663
+ );
664
+
665
+ // Database deadlock retry pattern
666
+ const result = await retryWithBackoff(
667
+ () => run(() => db.transaction(fn), classifyDbError),
668
+ {
669
+ attempts: 3,
670
+ delayMs: 50,
671
+ maxDelayMs: 500,
672
+ shouldRetry: (e) => e.code === 'DEADLOCK',
673
+ onRetry: (e, n) => logger.warn(`Deadlock retry #${n}`, e),
674
+ },
675
+ );
676
+ ```
677
+
678
+ #### Jitter
679
+
680
+ When many instances of your service fail at the same time (e.g. a downstream goes down), they all retry on the same schedule — creating a synchronized spike that can overwhelm the recovering service. Jitter spreads those retries across time.
681
+
682
+ ```typescript
683
+ // Full jitter delay = random(0, computedDelay)
684
+ // Maximum spread. Best for high-concurrency scenarios (many parallel clients).
685
+ await retryWithBackoff(() => run(() => callApi()), {
686
+ attempts: 4,
687
+ delayMs: 500,
688
+ maxDelayMs: 10_000,
689
+ jitter: true,
690
+ });
691
+
692
+ // Partial jitter — delay ± (computedDelay × factor)
693
+ // Keeps delays close to the backoff curve while adding noise.
694
+ // 0.25 = ±25%: a computed 1000ms delay becomes 750ms–1250ms.
695
+ await retryWithBackoff(() => run(() => callApi()), {
696
+ attempts: 4,
697
+ delayMs: 500,
698
+ maxDelayMs: 10_000,
699
+ jitter: 0.25,
700
+ });
701
+ ```
702
+
703
+ | `jitter` value | Behaviour | Use when |
704
+ |---|---|---|
705
+ | `false` / omitted | No randomness — deterministic delays | Tests, single-instance services |
706
+ | `true` | Full jitter: `random(0, delay)` | Many parallel clients retrying the same service |
707
+ | `0.0–1.0` | Partial jitter: `delay ± (delay × factor)` | You want backoff shape preserved with light noise |
708
+
709
+ ### `withTimeout(fn, ms, timeoutError)`
710
+
711
+ Races a Result-returning function against a deadline.
712
+
713
+ ```typescript
714
+ import { withTimeout, run } from '@backendkit-labs/result';
715
+
716
+ // Enforce SLA on external calls
717
+ const result = await withTimeout(
718
+ () => run(() => callSlowApi()),
719
+ 5_000,
720
+ new TimeoutError('API call exceeded 5s SLA'),
721
+ );
722
+
723
+ // With typed error
724
+ const result = await withTimeout<Report, ApiError>(
725
+ () => run(() => generateReport(params), toApiError),
726
+ 30_000,
727
+ { code: 'TIMEOUT', message: 'Report generation timed out' },
728
+ );
729
+
730
+ if (isFail(result) && result.error.code === 'TIMEOUT') {
731
+ return servePartialReport();
732
+ }
733
+ ```
734
+
735
+ ### Combining resilience primitives
736
+
737
+ ```typescript
738
+ // Retry with backoff + timeout on each attempt
739
+ const result = await withTimeout(
740
+ () => retryWithBackoff(
741
+ () => run(() => fetchCriticalData()),
742
+ { attempts: 3, delayMs: 100, maxDelayMs: 500 },
743
+ ),
744
+ 10_000,
745
+ new Error('Gave up after 10s'),
746
+ );
747
+ ```
748
+
749
+ ---
750
+
751
+ ## Combinators
752
+
753
+ ### `all(results)` — all must succeed
754
+
755
+ Returns `ok([...values])` or the first failure.
756
+
757
+ ```typescript
758
+ import { all, run } from '@backendkit-labs/result';
759
+
760
+ const [userResult, orderResult, inventoryResult] = await Promise.all([
761
+ run(() => fetchUser(userId)),
762
+ run(() => fetchOrder(orderId)),
763
+ run(() => fetchInventory(sku)),
764
+ ]);
765
+
766
+ const combined = all([userResult, orderResult, inventoryResult]);
767
+ // Result<[User, Order, Inventory], Error>
768
+
769
+ if (isOk(combined)) {
770
+ const [user, order, inventory] = combined.value;
771
+ }
772
+ ```
773
+
774
+ ### `any(operations)` first success wins
775
+
776
+ Tries operations sequentially, returns the first that succeeds.
777
+
778
+ ```typescript
779
+ import { any, run } from '@backendkit-labs/result';
780
+
781
+ // Cache DB fallback chain
782
+ const user = await any([
783
+ () => run(() => cache.get(id)),
784
+ () => run(() => replicaDb.findUser(id)),
785
+ () => run(() => primaryDb.findUser(id)),
786
+ ]);
787
+ ```
788
+
789
+ ### `parallel(operations, options?)` concurrent execution
790
+
791
+ Runs all operations concurrently (with optional concurrency limit). Returns all values or the first failure.
792
+
793
+ ```typescript
794
+ import { parallel, run } from '@backendkit-labs/result';
795
+
796
+ // Process all at once
797
+ const result = await parallel(
798
+ userIds.map(id => () => run(() => fetchUser(id))),
799
+ );
800
+ // Result<User[], Error>
801
+
802
+ // Limit concurrency to avoid overwhelming downstream
803
+ const result = await parallel(
804
+ imageIds.map(id => () => run(() => processImage(id))),
805
+ { concurrency: 5 },
806
+ );
807
+
808
+ if (isOk(result)) {
809
+ const users: User[] = result.value;
810
+ }
811
+ ```
812
+
813
+ ### `partition(results)` split successes and failures
814
+
815
+ ```typescript
816
+ import { partition } from '@backendkit-labs/result';
817
+
818
+ const results = await Promise.all(ids.map(id => run(() => fetchUser(id))));
819
+ const [users, errors] = partition(results);
820
+ // users: User[] — all successful values
821
+ // errors: Error[] — all failure values
822
+
823
+ logger.info(`Fetched ${users.length} users, ${errors.length} failed`);
824
+ ```
825
+
826
+ ### `collect(results)` success values only
827
+
828
+ Like `partition` but silently drops failures.
829
+
830
+ ```typescript
831
+ import { collect } from '@backendkit-labs/result';
832
+
833
+ const results = await Promise.all(ids.map(id => run(() => fetchUser(id))));
834
+ const users = collect(results);
835
+ // User[] failures are discarded
836
+ ```
837
+
838
+ ### `traverse(items, fn)` — map array through a Result function
839
+
840
+ Applies a Result-returning function to each item. Succeeds only if all items succeed (short-circuits on the first failure).
841
+
842
+ ```typescript
843
+ import { traverse, fromNullable } from '@backendkit-labs/result';
844
+
845
+ // Validate every item in an array
846
+ const result = traverse(
847
+ requestBody.items,
848
+ (item) => fromNullable(
849
+ catalog.get(item.sku),
850
+ { code: 'SKU_NOT_FOUND' as const, sku: item.sku },
851
+ ),
852
+ );
853
+ // Result<CatalogItem[], { code: 'SKU_NOT_FOUND'; sku: string }>
854
+
855
+ // Parse and validate a list of inputs
856
+ const result = traverse(
857
+ rawIds,
858
+ (id) => id.match(/^\d+$/)
859
+ ? ok(parseInt(id, 10))
860
+ : fail(`Invalid ID format: ${id}`),
861
+ );
862
+ ```
863
+
864
+ ### `combine2(r1, r2)` / `combine3(r1, r2, r3)` — typed tuples
865
+
866
+ Combines two or three results into a precisely typed tuple. Short-circuits on the first failure.
867
+
868
+ ```typescript
869
+ import { combine2, combine3, run } from '@backendkit-labs/result';
870
+
871
+ const result = combine2(
872
+ await run(() => fetchUser(userId)),
873
+ await run(() => fetchAccount(accountId)),
874
+ );
875
+ // Result<[User, Account], Error>
876
+
877
+ if (isOk(result)) {
878
+ const [user, account] = result.value; // fully typed
879
+ }
880
+
881
+ // Three results
882
+ const result = combine3(
883
+ await run(() => fetchUser(userId)),
884
+ await run(() => fetchPermissions(userId)),
885
+ await run(() => fetchSettings(userId)),
886
+ );
887
+ // Result<[User, Permission[], Settings], Error>
888
+ ```
889
+
890
+ ---
891
+
892
+ ## Flow Fluent Pipeline
893
+
894
+ `Flow<T, E>` is a composable wrapper that lets you build transformation pipelines. Each step is skipped if the result is already a failure.
895
+
896
+ ### Starting a pipeline
897
+
898
+ ```typescript
899
+ import { Flow, ok, fail } from '@backendkit-labs/result';
900
+
901
+ // From an existing result
902
+ const flow = Flow.from(ok(42));
903
+ Flow.from(await run(() => fetchUser(id)));
904
+
905
+ // Empty pipeline (value is void)
906
+ Flow.start().map(() => loadConfig());
907
+ ```
908
+
909
+ ### `.map(fn)` / `.mapError(fn)`
910
+
911
+ ```typescript
912
+ const result = Flow.from(await run(() => fetchUser(id)))
913
+ .map(user => user.profile)
914
+ .map(profile => profile.avatar ?? defaultAvatar)
915
+ .getResult();
916
+ // Result<string, Error>
917
+
918
+ // Transform errors along the way
919
+ const result = Flow.from(await run(() => callExternalApi(), toRawError))
920
+ .mapError(raw => new DomainError(raw.message, raw.code))
921
+ .getResult();
922
+ ```
923
+
924
+ ### `.flatMap(fn)`
925
+
926
+ ```typescript
927
+ const orderResult = Flow.from(await run(() => fetchUser(userId)))
928
+ .flatMap(user =>
929
+ user.activeOrderId
930
+ ? ok(user.activeOrderId)
931
+ : fail(new Error('No active order')),
932
+ )
933
+ .flatMap(orderId => fromNullable(ordersCache.get(orderId), new Error('Cache miss')))
934
+ .getResult();
935
+ ```
936
+
937
+ ### `.filter(predicate, error)`
938
+
939
+ ```typescript
940
+ const result = Flow.from(ok(age))
941
+ .filter(a => a >= 18, new Error('Must be 18 or older'))
942
+ .filter(a => a <= 120, new Error('Age value is unrealistic'))
943
+ .map(a => categorizeAge(a))
944
+ .getResult();
945
+ ```
946
+
947
+ ### `.tap(fn)` / `.tapError(fn)`
948
+
949
+ ```typescript
950
+ const result = Flow.from(await run(() => processPayment(dto)))
951
+ .tap(payment => analytics.track('payment.success', payment))
952
+ .tap(payment => cache.invalidate(`balance:${payment.userId}`))
953
+ .tapError(err => logger.error('Payment failed', err))
954
+ .tapError(err => metrics.increment('payment.failure'))
955
+ .getResult();
956
+ ```
957
+
958
+ ### `.recover(fn)`
959
+
960
+ Convert a failure into a success — useful for providing defaults.
961
+
962
+ ```typescript
963
+ const result = Flow.from(await run(() => fetchFromPrimary(key)))
964
+ .recover(error => {
965
+ logger.warn('Primary failed, using default', error);
966
+ return defaultValue;
967
+ })
968
+ .getResult();
969
+ // Result<T, never> failure branch is eliminated
970
+ ```
971
+
972
+ ### `.match(handlers)`
973
+
974
+ Terminate the pipeline with an exhaustive match.
975
+
976
+ ```typescript
977
+ const httpResponse = Flow.from(await run(() => processRequest(req)))
978
+ .map(data => ({ status: 200, body: data }))
979
+ .match({
980
+ ok: (response) => response,
981
+ fail: (error) => ({ status: 500, body: { message: error.message } }),
982
+ });
983
+ ```
984
+
985
+ ### Full pipeline example
986
+
987
+ ```typescript
988
+ const response = await Flow.from(
989
+ await track(
990
+ () => db.users.findOrThrow(userId),
991
+ { operation: 'user.fetch', tags: ['db'] },
992
+ ),
993
+ )
994
+ .tapError(e => logger.error('User not found', e))
995
+ .flatMap(user =>
996
+ user.isActive
997
+ ? ok(user)
998
+ : fail(new ForbiddenError('Account suspended')),
999
+ )
1000
+ .map(user => ({
1001
+ id: user.id,
1002
+ name: user.name,
1003
+ email: user.email,
1004
+ }))
1005
+ .tap(dto => cache.set(`user:${userId}`, dto, { ttl: 60 }))
1006
+ .match({
1007
+ ok: (dto) => ({ statusCode: 200, data: dto }),
1008
+ fail: (error) => ({
1009
+ statusCode: error instanceof ForbiddenError ? 403 : 404,
1010
+ message: error.message,
1011
+ }),
1012
+ });
1013
+ ```
1014
+
1015
+ ---
1016
+
1017
+ ## NestJS Integration
1018
+
1019
+ Import from the `/nestjs` subpath.
1020
+
1021
+ ```typescript
1022
+ import { ResultModule } from '@backendkit-labs/result/nestjs';
1023
+
1024
+ @Module({ imports: [ResultModule] })
1025
+ export class AppModule {}
1026
+ ```
1027
+
1028
+ ### `@AsResult(operation?)` — wrap method in `run()`
1029
+
1030
+ Any exception thrown inside the method becomes a `fail`. The return type becomes `Promise<Result<T, E>>`.
1031
+
1032
+ ```typescript
1033
+ import { AsResult } from '@backendkit-labs/result/nestjs';
1034
+ import { ok, fail, isOk } from '@backendkit-labs/result';
1035
+
1036
+ @Injectable()
1037
+ export class UserService {
1038
+ @AsResult('user.find')
1039
+ async findOne(id: string): Promise<User> {
1040
+ return this.db.users.findOrThrow(id); // throws → becomes fail()
1041
+ }
1042
+ }
1043
+
1044
+ // In the controller
1045
+ const result = await this.userService.findOne(id);
1046
+ // Result<User, Error>
1047
+ if (isOk(result)) {
1048
+ return result.value;
1049
+ }
1050
+ ```
1051
+
1052
+ ### `@WithMetrics(options?)` — wrap method in `track()`
1053
+
1054
+ Like `@AsResult()` but returns a `RichResult` with timing and metadata.
1055
+
1056
+ ```typescript
1057
+ import { WithMetrics } from '@backendkit-labs/result/nestjs';
1058
+ import { isOk } from '@backendkit-labs/result';
1059
+
1060
+ @Injectable()
1061
+ export class PaymentService {
1062
+ @WithMetrics({ operation: 'payment.charge', tags: ['stripe'] })
1063
+ async charge(dto: ChargeDto): Promise<Payment> {
1064
+ return this.stripeClient.charges.create({
1065
+ amount: dto.amount,
1066
+ currency: dto.currency,
1067
+ });
1068
+ }
1069
+ }
1070
+
1071
+ // In the controller
1072
+ const result = await this.paymentService.charge(dto);
1073
+ // RichResult<Payment, Error>
1074
+
1075
+ logger.info('Charge result', {
1076
+ ok: result.ok,
1077
+ operation: result.operation, // 'payment.charge'
1078
+ durationMs: result.durationMs, // e.g. 340
1079
+ tags: result.tags, // ['stripe']
1080
+ });
1081
+ ```
1082
+
1083
+ ### `ResultInterceptor` HTTP response normalization
1084
+
1085
+ Automatically converts `Result` and `RichResult` return values from controller methods into a consistent JSON response shape.
1086
+
1087
+ ```typescript
1088
+ import { ResultInterceptor } from '@backendkit-labs/result/nestjs';
1089
+
1090
+ // Global applies to every controller
1091
+ app.useGlobalInterceptors(app.get(ResultInterceptor));
1092
+
1093
+ // Or per-controller / per-route
1094
+ @UseInterceptors(ResultInterceptor)
1095
+ @Controller('users')
1096
+ export class UsersController { ... }
1097
+ ```
1098
+
1099
+ **Response shape for plain `Result`:**
1100
+
1101
+ ```json
1102
+ // Ok
1103
+ { "ok": true, "data": { "id": 1, "name": "Alice" } }
1104
+
1105
+ // Fail
1106
+ { "ok": false, "error": "User not found" }
1107
+ ```
1108
+
1109
+ **Response shape for `RichResult`:**
1110
+
1111
+ ```json
1112
+ // Ok
1113
+ {
1114
+ "ok": true,
1115
+ "data": { "id": 1, "name": "Alice" },
1116
+ "meta": {
1117
+ "operation": "user.find",
1118
+ "durationMs": 12,
1119
+ "timestamp": "2026-05-13T20:00:00.000Z",
1120
+ "correlationId": "req-abc-123",
1121
+ "tags": ["db", "users"]
1122
+ }
1123
+ }
1124
+
1125
+ // Fail
1126
+ {
1127
+ "ok": false,
1128
+ "error": "User not found",
1129
+ "meta": {
1130
+ "operation": "user.find",
1131
+ "durationMs": 3,
1132
+ "timestamp": "2026-05-13T20:00:00.001Z"
1133
+ }
1134
+ }
1135
+ ```
1136
+
1137
+ Non-Result return values (plain objects, arrays, primitives) pass through unchanged.
1138
+
1139
+ ### Full NestJS controller example
1140
+
1141
+ ```typescript
1142
+ import { Controller, Get, Post, Param, Body, UseInterceptors } from '@nestjs/common';
1143
+ import { ResultInterceptor } from '@backendkit-labs/result/nestjs';
1144
+ import { ok, fail, run, match, isOk } from '@backendkit-labs/result';
1145
+
1146
+ @UseInterceptors(ResultInterceptor)
1147
+ @Controller('payments')
1148
+ export class PaymentsController {
1149
+ constructor(private readonly paymentService: PaymentService) {}
1150
+
1151
+ @Post()
1152
+ async charge(@Body() dto: ChargeDto) {
1153
+ // RichResult normalized automatically by ResultInterceptor
1154
+ return this.paymentService.charge(dto);
1155
+ }
1156
+
1157
+ @Get(':id')
1158
+ async findOne(@Param('id') id: string) {
1159
+ const result = await this.paymentService.findOne(id);
1160
+
1161
+ // Handle 404 before returning — interceptor normalizes the rest
1162
+ return match(result, {
1163
+ ok: (payment) => ok(payment),
1164
+ fail: (error) => error.code === 'NOT_FOUND'
1165
+ ? fail(`Payment ${id} not found`)
1166
+ : fail('Internal error'),
1167
+ });
1168
+ }
1169
+ }
1170
+ ```
1171
+
1172
+ ---
1173
+
1174
+ ## Architecture
1175
+
1176
+ ```
1177
+ @backendkit-labs/result (core — zero runtime dependencies)
1178
+ Result<T, E> discriminated union, fully generic error type
1179
+ RichResult<T, E> Result + durationMs, timestamp, operation, tags
1180
+ ok() / fail() constructors
1181
+ fromThrowable() / fromPromise() exception capture
1182
+ fromNullable() null/undefined coercion
1183
+ isOk() / isFail() / isRich() type guards
1184
+ map() / mapError() / flatMap() transformations
1185
+ match() / fold() pattern matching
1186
+ tap() / tapError() side effects
1187
+ unwrap() / unwrapOr() / expect() unwrapping
1188
+ toPromise() / toNullable() conversion
1189
+ run() / track() async execution with error capture
1190
+ enrich() / simplify() RichResult promotion / demotion
1191
+ retry() / retryWithBackoff() resilience — retries
1192
+ withTimeout() resilience — deadline enforcement
1193
+ all() / any() / parallel() combinators — multiple results
1194
+ partition() / collect() / traverse() combinators — array operations
1195
+ combine2() / combine3() combinators — typed tuples
1196
+ Flow<T, E> fluent pipeline builder
1197
+
1198
+ @backendkit-labs/result/nestjs (optional NestJS layer)
1199
+ @AsResult() method decorator → run()
1200
+ @WithMetrics() method decorator → track()
1201
+ ResultInterceptor HTTP response normalization
1202
+ ResultModule NestJS module
1203
+ ```
1204
+
1205
+ The core is a pure TypeScript library with no runtime dependencies. The NestJS layer lives in a separate subpath export (`/nestjs`) and is tree-shaken from the core bundle.
1206
+
1207
+ ---
1208
+
1209
+ ## License
1210
+
1211
+ Apache-2.0 — [BackendKit Labs](https://github.com/backendkit-dev)