@backendkit-labs/result 0.1.3 → 0.2.0

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