@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 +1121 -0
- package/dist/index.cjs +340 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +281 -0
- package/dist/index.d.ts +281 -0
- package/dist/index.js +298 -0
- package/dist/index.js.map +1 -0
- package/dist/nestjs/index.cjs +117 -0
- package/dist/nestjs/index.cjs.map +1 -0
- package/dist/nestjs/index.d.cts +61 -0
- package/dist/nestjs/index.d.ts +61 -0
- package/dist/nestjs/index.js +114 -0
- package/dist/nestjs/index.js.map +1 -0
- package/dist/types-b-fNbJBy.d.cts +33 -0
- package/dist/types-b-fNbJBy.d.ts +33 -0
- package/package.json +70 -0
package/README.md
ADDED
|
@@ -0,0 +1,1121 @@
|
|
|
1
|
+
# @backendkit-labs/result
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/@backendkit-labs/result)
|
|
4
|
+
[](https://github.com/backendkit-dev/backendkit-monorepo/actions/workflows/ci.yml)
|
|
5
|
+
[](LICENSE)
|
|
6
|
+
[](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)
|