@backendkit-labs/result 0.1.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 ADDED
@@ -0,0 +1,1121 @@
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`.
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
+ ### `withTimeout(fn, ms, timeoutError)`
620
+
621
+ Races a Result-returning function against a deadline.
622
+
623
+ ```typescript
624
+ import { withTimeout, run } from '@backendkit-labs/result';
625
+
626
+ // Enforce SLA on external calls
627
+ const result = await withTimeout(
628
+ () => run(() => callSlowApi()),
629
+ 5_000,
630
+ new TimeoutError('API call exceeded 5s SLA'),
631
+ );
632
+
633
+ // With typed error
634
+ const result = await withTimeout<Report, ApiError>(
635
+ () => run(() => generateReport(params), toApiError),
636
+ 30_000,
637
+ { code: 'TIMEOUT', message: 'Report generation timed out' },
638
+ );
639
+
640
+ if (isFail(result) && result.error.code === 'TIMEOUT') {
641
+ return servePartialReport();
642
+ }
643
+ ```
644
+
645
+ ### Combining resilience primitives
646
+
647
+ ```typescript
648
+ // Retry with backoff + timeout on each attempt
649
+ const result = await withTimeout(
650
+ () => retryWithBackoff(
651
+ () => run(() => fetchCriticalData()),
652
+ { attempts: 3, delayMs: 100, maxDelayMs: 500 },
653
+ ),
654
+ 10_000,
655
+ new Error('Gave up after 10s'),
656
+ );
657
+ ```
658
+
659
+ ---
660
+
661
+ ## Combinators
662
+
663
+ ### `all(results)` — all must succeed
664
+
665
+ Returns `ok([...values])` or the first failure.
666
+
667
+ ```typescript
668
+ import { all, run } from '@backendkit-labs/result';
669
+
670
+ const [userResult, orderResult, inventoryResult] = await Promise.all([
671
+ run(() => fetchUser(userId)),
672
+ run(() => fetchOrder(orderId)),
673
+ run(() => fetchInventory(sku)),
674
+ ]);
675
+
676
+ const combined = all([userResult, orderResult, inventoryResult]);
677
+ // Result<[User, Order, Inventory], Error>
678
+
679
+ if (isOk(combined)) {
680
+ const [user, order, inventory] = combined.value;
681
+ }
682
+ ```
683
+
684
+ ### `any(operations)` — first success wins
685
+
686
+ Tries operations sequentially, returns the first that succeeds.
687
+
688
+ ```typescript
689
+ import { any, run } from '@backendkit-labs/result';
690
+
691
+ // Cache → DB fallback chain
692
+ const user = await any([
693
+ () => run(() => cache.get(id)),
694
+ () => run(() => replicaDb.findUser(id)),
695
+ () => run(() => primaryDb.findUser(id)),
696
+ ]);
697
+ ```
698
+
699
+ ### `parallel(operations, options?)` — concurrent execution
700
+
701
+ Runs all operations concurrently (with optional concurrency limit). Returns all values or the first failure.
702
+
703
+ ```typescript
704
+ import { parallel, run } from '@backendkit-labs/result';
705
+
706
+ // Process all at once
707
+ const result = await parallel(
708
+ userIds.map(id => () => run(() => fetchUser(id))),
709
+ );
710
+ // Result<User[], Error>
711
+
712
+ // Limit concurrency to avoid overwhelming downstream
713
+ const result = await parallel(
714
+ imageIds.map(id => () => run(() => processImage(id))),
715
+ { concurrency: 5 },
716
+ );
717
+
718
+ if (isOk(result)) {
719
+ const users: User[] = result.value;
720
+ }
721
+ ```
722
+
723
+ ### `partition(results)` — split successes and failures
724
+
725
+ ```typescript
726
+ import { partition } from '@backendkit-labs/result';
727
+
728
+ const results = await Promise.all(ids.map(id => run(() => fetchUser(id))));
729
+ const [users, errors] = partition(results);
730
+ // users: User[] — all successful values
731
+ // errors: Error[] — all failure values
732
+
733
+ logger.info(`Fetched ${users.length} users, ${errors.length} failed`);
734
+ ```
735
+
736
+ ### `collect(results)` — success values only
737
+
738
+ Like `partition` but silently drops failures.
739
+
740
+ ```typescript
741
+ import { collect } from '@backendkit-labs/result';
742
+
743
+ const results = await Promise.all(ids.map(id => run(() => fetchUser(id))));
744
+ const users = collect(results);
745
+ // User[] — failures are discarded
746
+ ```
747
+
748
+ ### `traverse(items, fn)` — map array through a Result function
749
+
750
+ Applies a Result-returning function to each item. Succeeds only if all items succeed (short-circuits on the first failure).
751
+
752
+ ```typescript
753
+ import { traverse, fromNullable } from '@backendkit-labs/result';
754
+
755
+ // Validate every item in an array
756
+ const result = traverse(
757
+ requestBody.items,
758
+ (item) => fromNullable(
759
+ catalog.get(item.sku),
760
+ { code: 'SKU_NOT_FOUND' as const, sku: item.sku },
761
+ ),
762
+ );
763
+ // Result<CatalogItem[], { code: 'SKU_NOT_FOUND'; sku: string }>
764
+
765
+ // Parse and validate a list of inputs
766
+ const result = traverse(
767
+ rawIds,
768
+ (id) => id.match(/^\d+$/)
769
+ ? ok(parseInt(id, 10))
770
+ : fail(`Invalid ID format: ${id}`),
771
+ );
772
+ ```
773
+
774
+ ### `combine2(r1, r2)` / `combine3(r1, r2, r3)` — typed tuples
775
+
776
+ Combines two or three results into a precisely typed tuple. Short-circuits on the first failure.
777
+
778
+ ```typescript
779
+ import { combine2, combine3, run } from '@backendkit-labs/result';
780
+
781
+ const result = combine2(
782
+ await run(() => fetchUser(userId)),
783
+ await run(() => fetchAccount(accountId)),
784
+ );
785
+ // Result<[User, Account], Error>
786
+
787
+ if (isOk(result)) {
788
+ const [user, account] = result.value; // fully typed
789
+ }
790
+
791
+ // Three results
792
+ const result = combine3(
793
+ await run(() => fetchUser(userId)),
794
+ await run(() => fetchPermissions(userId)),
795
+ await run(() => fetchSettings(userId)),
796
+ );
797
+ // Result<[User, Permission[], Settings], Error>
798
+ ```
799
+
800
+ ---
801
+
802
+ ## Flow — Fluent Pipeline
803
+
804
+ `Flow<T, E>` is a composable wrapper that lets you build transformation pipelines. Each step is skipped if the result is already a failure.
805
+
806
+ ### Starting a pipeline
807
+
808
+ ```typescript
809
+ import { Flow, ok, fail } from '@backendkit-labs/result';
810
+
811
+ // From an existing result
812
+ const flow = Flow.from(ok(42));
813
+ Flow.from(await run(() => fetchUser(id)));
814
+
815
+ // Empty pipeline (value is void)
816
+ Flow.start().map(() => loadConfig());
817
+ ```
818
+
819
+ ### `.map(fn)` / `.mapError(fn)`
820
+
821
+ ```typescript
822
+ const result = Flow.from(await run(() => fetchUser(id)))
823
+ .map(user => user.profile)
824
+ .map(profile => profile.avatar ?? defaultAvatar)
825
+ .getResult();
826
+ // Result<string, Error>
827
+
828
+ // Transform errors along the way
829
+ const result = Flow.from(await run(() => callExternalApi(), toRawError))
830
+ .mapError(raw => new DomainError(raw.message, raw.code))
831
+ .getResult();
832
+ ```
833
+
834
+ ### `.flatMap(fn)`
835
+
836
+ ```typescript
837
+ const orderResult = Flow.from(await run(() => fetchUser(userId)))
838
+ .flatMap(user =>
839
+ user.activeOrderId
840
+ ? ok(user.activeOrderId)
841
+ : fail(new Error('No active order')),
842
+ )
843
+ .flatMap(orderId => fromNullable(ordersCache.get(orderId), new Error('Cache miss')))
844
+ .getResult();
845
+ ```
846
+
847
+ ### `.filter(predicate, error)`
848
+
849
+ ```typescript
850
+ const result = Flow.from(ok(age))
851
+ .filter(a => a >= 18, new Error('Must be 18 or older'))
852
+ .filter(a => a <= 120, new Error('Age value is unrealistic'))
853
+ .map(a => categorizeAge(a))
854
+ .getResult();
855
+ ```
856
+
857
+ ### `.tap(fn)` / `.tapError(fn)`
858
+
859
+ ```typescript
860
+ const result = Flow.from(await run(() => processPayment(dto)))
861
+ .tap(payment => analytics.track('payment.success', payment))
862
+ .tap(payment => cache.invalidate(`balance:${payment.userId}`))
863
+ .tapError(err => logger.error('Payment failed', err))
864
+ .tapError(err => metrics.increment('payment.failure'))
865
+ .getResult();
866
+ ```
867
+
868
+ ### `.recover(fn)`
869
+
870
+ Convert a failure into a success — useful for providing defaults.
871
+
872
+ ```typescript
873
+ const result = Flow.from(await run(() => fetchFromPrimary(key)))
874
+ .recover(error => {
875
+ logger.warn('Primary failed, using default', error);
876
+ return defaultValue;
877
+ })
878
+ .getResult();
879
+ // Result<T, never> — failure branch is eliminated
880
+ ```
881
+
882
+ ### `.match(handlers)`
883
+
884
+ Terminate the pipeline with an exhaustive match.
885
+
886
+ ```typescript
887
+ const httpResponse = Flow.from(await run(() => processRequest(req)))
888
+ .map(data => ({ status: 200, body: data }))
889
+ .match({
890
+ ok: (response) => response,
891
+ fail: (error) => ({ status: 500, body: { message: error.message } }),
892
+ });
893
+ ```
894
+
895
+ ### Full pipeline example
896
+
897
+ ```typescript
898
+ const response = await Flow.from(
899
+ await track(
900
+ () => db.users.findOrThrow(userId),
901
+ { operation: 'user.fetch', tags: ['db'] },
902
+ ),
903
+ )
904
+ .tapError(e => logger.error('User not found', e))
905
+ .flatMap(user =>
906
+ user.isActive
907
+ ? ok(user)
908
+ : fail(new ForbiddenError('Account suspended')),
909
+ )
910
+ .map(user => ({
911
+ id: user.id,
912
+ name: user.name,
913
+ email: user.email,
914
+ }))
915
+ .tap(dto => cache.set(`user:${userId}`, dto, { ttl: 60 }))
916
+ .match({
917
+ ok: (dto) => ({ statusCode: 200, data: dto }),
918
+ fail: (error) => ({
919
+ statusCode: error instanceof ForbiddenError ? 403 : 404,
920
+ message: error.message,
921
+ }),
922
+ });
923
+ ```
924
+
925
+ ---
926
+
927
+ ## NestJS Integration
928
+
929
+ Import from the `/nestjs` subpath.
930
+
931
+ ```typescript
932
+ import { ResultModule } from '@backendkit-labs/result/nestjs';
933
+
934
+ @Module({ imports: [ResultModule] })
935
+ export class AppModule {}
936
+ ```
937
+
938
+ ### `@AsResult(operation?)` — wrap method in `run()`
939
+
940
+ Any exception thrown inside the method becomes a `fail`. The return type becomes `Promise<Result<T, E>>`.
941
+
942
+ ```typescript
943
+ import { AsResult } from '@backendkit-labs/result/nestjs';
944
+ import { ok, fail, isOk } from '@backendkit-labs/result';
945
+
946
+ @Injectable()
947
+ export class UserService {
948
+ @AsResult('user.find')
949
+ async findOne(id: string): Promise<User> {
950
+ return this.db.users.findOrThrow(id); // throws → becomes fail()
951
+ }
952
+ }
953
+
954
+ // In the controller
955
+ const result = await this.userService.findOne(id);
956
+ // Result<User, Error>
957
+ if (isOk(result)) {
958
+ return result.value;
959
+ }
960
+ ```
961
+
962
+ ### `@WithMetrics(options?)` — wrap method in `track()`
963
+
964
+ Like `@AsResult()` but returns a `RichResult` with timing and metadata.
965
+
966
+ ```typescript
967
+ import { WithMetrics } from '@backendkit-labs/result/nestjs';
968
+ import { isOk } from '@backendkit-labs/result';
969
+
970
+ @Injectable()
971
+ export class PaymentService {
972
+ @WithMetrics({ operation: 'payment.charge', tags: ['stripe'] })
973
+ async charge(dto: ChargeDto): Promise<Payment> {
974
+ return this.stripeClient.charges.create({
975
+ amount: dto.amount,
976
+ currency: dto.currency,
977
+ });
978
+ }
979
+ }
980
+
981
+ // In the controller
982
+ const result = await this.paymentService.charge(dto);
983
+ // RichResult<Payment, Error>
984
+
985
+ logger.info('Charge result', {
986
+ ok: result.ok,
987
+ operation: result.operation, // 'payment.charge'
988
+ durationMs: result.durationMs, // e.g. 340
989
+ tags: result.tags, // ['stripe']
990
+ });
991
+ ```
992
+
993
+ ### `ResultInterceptor` — HTTP response normalization
994
+
995
+ Automatically converts `Result` and `RichResult` return values from controller methods into a consistent JSON response shape.
996
+
997
+ ```typescript
998
+ import { ResultInterceptor } from '@backendkit-labs/result/nestjs';
999
+
1000
+ // Global — applies to every controller
1001
+ app.useGlobalInterceptors(app.get(ResultInterceptor));
1002
+
1003
+ // Or per-controller / per-route
1004
+ @UseInterceptors(ResultInterceptor)
1005
+ @Controller('users')
1006
+ export class UsersController { ... }
1007
+ ```
1008
+
1009
+ **Response shape for plain `Result`:**
1010
+
1011
+ ```json
1012
+ // Ok
1013
+ { "ok": true, "data": { "id": 1, "name": "Alice" } }
1014
+
1015
+ // Fail
1016
+ { "ok": false, "error": "User not found" }
1017
+ ```
1018
+
1019
+ **Response shape for `RichResult`:**
1020
+
1021
+ ```json
1022
+ // Ok
1023
+ {
1024
+ "ok": true,
1025
+ "data": { "id": 1, "name": "Alice" },
1026
+ "meta": {
1027
+ "operation": "user.find",
1028
+ "durationMs": 12,
1029
+ "timestamp": "2026-05-13T20:00:00.000Z",
1030
+ "correlationId": "req-abc-123",
1031
+ "tags": ["db", "users"]
1032
+ }
1033
+ }
1034
+
1035
+ // Fail
1036
+ {
1037
+ "ok": false,
1038
+ "error": "User not found",
1039
+ "meta": {
1040
+ "operation": "user.find",
1041
+ "durationMs": 3,
1042
+ "timestamp": "2026-05-13T20:00:00.001Z"
1043
+ }
1044
+ }
1045
+ ```
1046
+
1047
+ Non-Result return values (plain objects, arrays, primitives) pass through unchanged.
1048
+
1049
+ ### Full NestJS controller example
1050
+
1051
+ ```typescript
1052
+ import { Controller, Get, Post, Param, Body, UseInterceptors } from '@nestjs/common';
1053
+ import { ResultInterceptor } from '@backendkit-labs/result/nestjs';
1054
+ import { ok, fail, run, match, isOk } from '@backendkit-labs/result';
1055
+
1056
+ @UseInterceptors(ResultInterceptor)
1057
+ @Controller('payments')
1058
+ export class PaymentsController {
1059
+ constructor(private readonly paymentService: PaymentService) {}
1060
+
1061
+ @Post()
1062
+ async charge(@Body() dto: ChargeDto) {
1063
+ // RichResult normalized automatically by ResultInterceptor
1064
+ return this.paymentService.charge(dto);
1065
+ }
1066
+
1067
+ @Get(':id')
1068
+ async findOne(@Param('id') id: string) {
1069
+ const result = await this.paymentService.findOne(id);
1070
+
1071
+ // Handle 404 before returning — interceptor normalizes the rest
1072
+ return match(result, {
1073
+ ok: (payment) => ok(payment),
1074
+ fail: (error) => error.code === 'NOT_FOUND'
1075
+ ? fail(`Payment ${id} not found`)
1076
+ : fail('Internal error'),
1077
+ });
1078
+ }
1079
+ }
1080
+ ```
1081
+
1082
+ ---
1083
+
1084
+ ## Architecture
1085
+
1086
+ ```
1087
+ @backendkit-labs/result (core — zero runtime dependencies)
1088
+ Result<T, E> discriminated union, fully generic error type
1089
+ RichResult<T, E> Result + durationMs, timestamp, operation, tags
1090
+ ok() / fail() constructors
1091
+ fromThrowable() / fromPromise() exception capture
1092
+ fromNullable() null/undefined coercion
1093
+ isOk() / isFail() / isRich() type guards
1094
+ map() / mapError() / flatMap() transformations
1095
+ match() / fold() pattern matching
1096
+ tap() / tapError() side effects
1097
+ unwrap() / unwrapOr() / expect() unwrapping
1098
+ toPromise() / toNullable() conversion
1099
+ run() / track() async execution with error capture
1100
+ enrich() / simplify() RichResult promotion / demotion
1101
+ retry() / retryWithBackoff() resilience — retries
1102
+ withTimeout() resilience — deadline enforcement
1103
+ all() / any() / parallel() combinators — multiple results
1104
+ partition() / collect() / traverse() combinators — array operations
1105
+ combine2() / combine3() combinators — typed tuples
1106
+ Flow<T, E> fluent pipeline builder
1107
+
1108
+ @backendkit-labs/result/nestjs (optional NestJS layer)
1109
+ @AsResult() method decorator → run()
1110
+ @WithMetrics() method decorator → track()
1111
+ ResultInterceptor HTTP response normalization
1112
+ ResultModule NestJS module
1113
+ ```
1114
+
1115
+ 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.
1116
+
1117
+ ---
1118
+
1119
+ ## License
1120
+
1121
+ MIT — [BackendKit Labs](https://github.com/backendkit-dev)