resultar 3.4.0 → 3.5.1

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,52 +1,55 @@
1
1
  # Resultar
2
2
 
3
- Production-facing error handling for TypeScript, without a runtime.
3
+ Typed, composable error handling for TypeScript.
4
4
 
5
- Full documentation, long-form recipes, and the complete API map live in
6
- [DOCUMENTATION.md](../../DOCUMENTATION.md).
7
-
8
- ## New In 3.1
9
-
10
- Resultar 3.1 turns common production async policies into typed values instead of rejected promises,
11
- hidden timers, or helper code that throws.
12
-
13
- - `ResultAsync.timeout`, `retry`, `retryOrElse`, `race`, `raceAll`, and `withResource` cover
14
- timeouts, retry policy, fallback, replica reads, and cleanup while preserving the expected error
15
- type.
16
- - `ResultAsync.forEach` and `ResultAsync.validateAll` support sequential work, bounded
17
- `{ concurrency }`, and explicit unbounded execution for batch processing and validation.
18
- - `AbortError` / `isAbortError` and redacted tagged-error props with `redact`, `isRedacted`, and
19
- `revealRedacted` make cancellation and sensitive error metadata visible at the type boundary.
20
- - `pipe` lets teams package reusable result combinators without hiding the underlying `Result` /
21
- `ResultAsync` type.
22
-
23
- ## Main Functionalities
24
-
25
- Resultar gives you typed `Result` primitives, real `Error`-based tagged failures, async control
26
- helpers, sync/async composition, and optional no-discard diagnostics. It is small enough to adopt in
27
- one module and strict enough to use at service, HTTP, job, queue, CLI, and integration boundaries.
5
+ Resultar makes expected failures visible in function signatures instead of hiding them behind
6
+ `throw`, rejected promises, nullable values, or undocumented conventions.
28
7
 
29
8
  ```ts
30
9
  Result<T, E>
31
10
  ResultAsync<T, E>
32
- StrictResult<T, E extends Error>
33
- StrictResultAsync<T, E extends Error>
34
11
  ```
35
12
 
36
- Use it when expected failures should be impossible to miss:
13
+ - `T` is the success value.
14
+ - `E` is the expected failure.
15
+ - Callers must handle the result before they can use the value.
37
16
 
38
- - Function signatures show both the success type and the expected error type.
39
- - Domain errors are real `Error` instances with stable tags, messages, `cause`, stack traces, and
40
- JSON output.
41
- - `ResultAsync` can bound concurrent work, race replicas, apply typed timeouts, retry transient
42
- failures, and pair acquisition with cleanup without adding a runtime scheduler.
43
- - Sync and async fallible work compose without `try/catch` blocks scattered through application
44
- code.
45
- - Ignored `Result` values can be reported by a type-aware no-discard check.
17
+ Resultar stays focused on explicit values. It does not require a framework, dependency injection
18
+ container, scheduler, or application runtime.
46
19
 
47
- Resultar began as an initial fork of `neverthrow`. The v3 line keeps the explicit wrapper model,
48
- then leans into Resultar-specific tagged errors, strict service-boundary types, TypeScript 7-first
49
- diagnostics, and ESM-only packaging.
20
+ ## Highlights
21
+
22
+ | You need | Resultar gives you |
23
+ | --- | --- |
24
+ | Expected failures in the type system | `Result<T, E>` and `ResultAsync<T, E>` |
25
+ | Production errors with useful metadata | Real `Error` subclasses from `createTaggedError` |
26
+ | Composable sync and async workflows | `map`, `mapErr`, `andThen`, `asyncAndThen`, and `orElse` |
27
+ | Linear code without scattered `try/catch` | `safeTry` with `yield*` short-circuiting |
28
+ | Explicit production async policy | Typed timeout, retry, race, concurrency, and cleanup helpers |
29
+ | Exhaustive application boundaries | `matchTags` for tagged error unions |
30
+ | Guardrails for ignored results | The optional `resultar-check` CLI and language-server plugin |
31
+
32
+ Resultar began as a fork of `neverthrow`. The current API keeps the familiar explicit Result model
33
+ and adds Resultar-specific tagged errors, production async helpers, exhaustive tagged boundaries,
34
+ and optional TypeScript-backed diagnostics.
35
+
36
+ ## Contents
37
+
38
+ - [Install](#install)
39
+ - [Why Resultar](#why-resultar)
40
+ - [Quick Start](#quick-start)
41
+ - [Compose Results](#compose-results)
42
+ - [Tagged Errors](#tagged-errors)
43
+ - [Async Work](#async-work)
44
+ - [Production Async Policies](#production-async-policies)
45
+ - [Linear Workflows With safeTry](#linear-workflows-with-safetry)
46
+ - [Recover Inside A Workflow](#recover-inside-a-workflow)
47
+ - [Handle Results At Boundaries](#handle-results-at-boundaries)
48
+ - [Strict Results At Production Boundaries](#strict-results-at-production-boundaries)
49
+ - [Prevent Ignored Results](#prevent-ignored-results)
50
+ - [HTTP Request Packages](#http-request-packages)
51
+ - [API Decision Guide](#api-decision-guide)
52
+ - [More Documentation](#more-documentation)
50
53
 
51
54
  ## Install
52
55
 
@@ -58,927 +61,592 @@ pnpm add resultar
58
61
  npm install resultar
59
62
  ```
60
63
 
61
- ## Requirements
64
+ Requirements:
62
65
 
63
66
  - Node.js 24+
64
- - TypeScript 7 for the canonical Resultar diagnostics workflow
65
67
  - ESM only
68
+ - TypeScript 7+ for the canonical `resultar-check` diagnostics workflow
66
69
 
67
70
  ```ts
68
71
  import { createTaggedError, ok } from 'resultar'
69
- import type { StrictResult } from 'resultar'
72
+ import type { Result } from 'resultar'
70
73
  ```
71
74
 
72
75
  CommonJS `require('resultar')` is not exported.
73
76
 
74
- ## Why Teams Use It
77
+ ## Why Resultar
75
78
 
76
- This signature hides an expected failure:
79
+ This signature does not tell callers that parsing can fail:
77
80
 
78
81
  ```ts
79
- const parsePort = (value: string): number => {
80
- const port = Number(value)
82
+ const parsePort = (input: string): number => {
83
+ const port = Number(input)
81
84
 
82
85
  if (!Number.isInteger(port) || port <= 0) {
83
- throw new Error(`Invalid port ${value}`)
86
+ throw new Error(`Invalid port ${input}`)
84
87
  }
85
88
 
86
89
  return port
87
90
  }
88
91
  ```
89
92
 
90
- This signature makes the failure part of the contract:
93
+ Resultar moves the expected failure into the contract:
91
94
 
92
95
  ```ts
93
96
  import { createTaggedError, ok } from 'resultar'
94
- import type { StrictResult } from 'resultar'
97
+ import type { Result } from 'resultar'
95
98
 
96
99
  class InvalidPortError extends createTaggedError({
97
100
  name: 'InvalidPortError',
98
- message: 'Invalid port $value',
101
+ message: 'Invalid port $input',
99
102
  }) {}
100
103
 
101
- const parsePort = (value: string): StrictResult<number, InvalidPortError> => {
102
- const port = Number(value)
103
-
104
- return Number.isInteger(port) && port > 0 ? ok(port) : InvalidPortError.err({ value })
105
- }
106
- ```
107
-
108
- Callers now know the success type and the expected error type without reading the implementation.
109
- That is the main trade: expected failures become visible contracts instead of tribal knowledge.
110
-
111
- Resultar is intentionally not an application runtime. It gives you explicit error values that work
112
- in normal TypeScript.
113
-
114
- ## What You Get
115
-
116
- | Problem | Resultar gives you |
117
- | --- | --- |
118
- | Expected failures are hidden behind `throw` | `Result<T, E>` and `ResultAsync<T, E>` in function signatures |
119
- | Production errors need stable names and metadata | `createTaggedError` classes with `_tag`, template props, `cause`, and `.toJSON()` |
120
- | Async flows turn into nested `try/catch` | `tryResultAsync`, `andThen`, `asyncAndThen`, `map`, `orElse`, `safeTry` |
121
- | Repeated result transforms become noisy | `pipe` with small reusable combinators |
122
- | Network calls need timeouts, retries, and fallback policy | `ResultAsync.timeout`, `retry`, `retryOrElse`, `race`, and `raceAll` |
123
- | Batch work needs backpressure without a framework | `ResultAsync.forEach` and mapped `validateAll` with `{ concurrency }` |
124
- | Resourceful work needs cleanup on every path | `ResultAsync.withResource` and native `AsyncIterable<Result<T, E>>` recipes |
125
- | Boundary handlers miss cases | `matchTags` for exhaustive tagged-error handling |
126
- | Results are easy to ignore | `resultar-check` and the `resultar-check` TypeScript plugin |
127
- | You do not want another application runtime | A small ESM library around explicit values |
128
-
129
- Nested `try/catch` often masks the error the caller actually needs:
130
-
131
- ```ts
132
- const getPosts = async (url: string) => {
133
- try {
134
- const response = await fetch(url)
135
-
136
- if (response.status >= 400) {
137
- throw new HttpClientError(response.status)
138
- }
104
+ const parsePort = (input: string): Result<number, InvalidPortError> => {
105
+ const port = Number(input)
139
106
 
140
- return response.json() as Promise<Post[]>
141
- } catch (cause) {
142
- throw new InfrastructureError({ cause })
143
- }
107
+ return Number.isInteger(port) && port > 0 ? ok(port) : InvalidPortError.err({ input })
144
108
  }
145
109
  ```
146
110
 
147
- Resultar keeps each failure explicit and typed:
111
+ The caller can now see both outcomes without reading the implementation:
148
112
 
149
113
  ```ts
150
- const getPosts = (
151
- url: string,
152
- ): StrictResultAsync<Post[], FetchPostsError | HttpClientError | ParsePostsError> =>
153
- tryResultAsync(fetch(url), (cause) => new FetchPostsError({ cause }))
154
- .andThen((response) =>
155
- response.status >= 400 ? HttpClientError.err({ status: response.status }) : ok(response),
156
- )
157
- .andThen((response) =>
158
- tryResultAsync(response.json() as Promise<Post[]>, (cause) => new ParsePostsError({ cause })),
159
- )
160
- ```
161
-
162
- The same flow can be written linearly with `safeTry` when the chain gets harder to scan:
163
-
164
- ```ts
165
- const getPostsLinear = (
166
- url: string,
167
- ): StrictResultAsync<Post[], FetchPostsError | HttpClientError | ParsePostsError> =>
168
- safeTry(async function* () {
169
- const response = yield* tryResultAsync(
170
- fetch(url),
171
- (cause) => new FetchPostsError({ cause }),
172
- )
173
-
174
- if (response.status >= 400) {
175
- return HttpClientError.err({ status: response.status })
176
- }
177
-
178
- const posts = yield* tryResultAsync(
179
- response.json() as Promise<Post[]>,
180
- (cause) => new ParsePostsError({ cause }),
181
- )
114
+ const port = parsePort(process.env.PORT ?? '3000')
182
115
 
183
- return ok(posts)
184
- })
185
- ```
186
-
187
- ## Async Control Without A Runtime
188
-
189
- The 3.1 async helpers cover common production workflows while keeping plain `Promise` and
190
- `AbortSignal`-compatible APIs underneath. You choose the policy in the type signature instead of
191
- hiding it in a helper that throws.
192
-
193
- Retry transient work and fall back to cache only after the retry policy is exhausted:
194
-
195
- ```ts
196
- const user = ResultAsync.retryOrElse(
197
- (attempt, signal) => fetchUser(id, { attempt, signal }),
198
- {
199
- times: 2,
200
- delayMs: ({ nextAttempt }) => nextAttempt * 100,
201
- jittered: 0.5,
202
- while: (error) => error._tag === 'RateLimitError' || error._tag === 'ServiceUnavailableError',
203
- orElse: () => readCachedUser(id),
204
- },
205
- )
206
- ```
207
-
208
- `jittered` randomizes the computed retry delay by a percentage factor to avoid synchronized retries.
209
- For example, `delayMs: 100` with `jittered: 0.5` waits between `50ms` and `150ms`; omit it or use
210
- `0` to keep the exact delay.
211
-
212
- Race a primary and replica with cooperative loser abort:
213
-
214
- ```ts
215
- const user = ResultAsync.race(
216
- (signal) => readUserFromPrimary(id, { signal }),
217
- (signal) => readUserFromReplica(id, { signal }),
116
+ const message = port.match(
117
+ (value) => `Listening on ${value}`,
118
+ (error) => error.message,
218
119
  )
219
120
  ```
220
121
 
221
- Keep timeout as a typed `Err`, not a rejected promise:
222
-
223
- ```ts
224
- const user = ResultAsync.timeout(
225
- (signal) => readUserFromPrimary(id, { signal }),
226
- {
227
- timeoutMs: 1_500,
228
- onTimeout: () => new FetchUserTimeoutError({ id, timeoutMs: 1_500 }),
229
- },
230
- )
231
- ```
122
+ Use `Err` for failures the caller can reasonably handle: invalid input, conflicts, missing records,
123
+ rate limits, timeouts, and unavailable dependencies. Keep programming bugs and impossible states as
124
+ normal JavaScript throws.
232
125
 
233
- Process batches with bounded concurrency and explicit cleanup:
126
+ ## Quick Start
234
127
 
235
- ```ts
236
- const imported = ResultAsync.withResource({
237
- acquire: (signal) => openImportSession({ signal }),
238
- use: (session, signal) =>
239
- ResultAsync.forEach(
240
- rows,
241
- (row) => importRow(session, row, { signal }),
242
- { concurrency: 8, discard: true },
243
- ),
244
- release: (session) => session.close(),
245
- })
246
- ```
128
+ The examples below build one account-creation workflow.
247
129
 
248
- ## Quick Start
130
+ Start with real tagged errors. Variables in the message template become required constructor props:
249
131
 
250
132
  ```ts
251
133
  import { createTaggedError, ok } from 'resultar'
252
- import type { StrictResult } from 'resultar'
134
+ import type { Result } from 'resultar'
253
135
 
254
136
  class InvalidEmailError extends createTaggedError({
255
137
  name: 'InvalidEmailError',
256
138
  message: 'Invalid email $email',
257
139
  }) {}
258
140
 
259
- class UserAlreadyExistsError extends createTaggedError({
260
- name: 'UserAlreadyExistsError',
261
- message: 'User $email already exists',
141
+ class AccountAlreadyExistsError extends createTaggedError({
142
+ name: 'AccountAlreadyExistsError',
143
+ message: 'Account $email already exists',
262
144
  }) {}
263
145
 
264
- type User = {
146
+ type Account = {
265
147
  readonly email: string
266
148
  readonly id: string
267
149
  }
150
+ ```
268
151
 
269
- const validateEmail = (email: string): StrictResult<string, InvalidEmailError> =>
270
- email.includes('@') ? ok(email) : InvalidEmailError.err({ email })
152
+ Return `Ok` for success and `Err` for an expected failure:
271
153
 
272
- const ensureUserDoesNotExist = (
273
- email: string,
274
- ): StrictResult<string, UserAlreadyExistsError> =>
275
- email === 'taken@example.com' ? UserAlreadyExistsError.err({ email }) : ok(email)
154
+ ```ts
155
+ const normalizeEmail = (input: string): Result<string, InvalidEmailError> => {
156
+ const email = input.trim().toLowerCase()
276
157
 
277
- const insertUser = (email: string): StrictResult<User, never> =>
278
- ok({ email, id: 'usr_123' })
158
+ return email.includes('@') ? ok(email) : InvalidEmailError.err({ email })
159
+ }
279
160
 
280
- const createUser = (
161
+ const ensureAccountIsAvailable = (
281
162
  email: string,
282
- ): StrictResult<User, InvalidEmailError | UserAlreadyExistsError> =>
283
- validateEmail(email).andThen(ensureUserDoesNotExist).andThen(insertUser)
163
+ ): Result<string, AccountAlreadyExistsError> =>
164
+ email === 'taken@example.com' ? AccountAlreadyExistsError.err({ email }) : ok(email)
284
165
 
285
- const response = createUser('taken@example.com').matchTags(
286
- (user) => ({ body: user, statusCode: 201 }),
287
- {
288
- InvalidEmailError: (error) => ({
289
- body: { code: error._tag, message: error.message },
290
- statusCode: 400,
291
- }),
292
- UserAlreadyExistsError: (error) => ({
293
- body: { code: error._tag, message: error.message },
294
- statusCode: 409,
295
- }),
296
- },
297
- )
166
+ const buildAccount = (email: string): Result<Account, never> =>
167
+ ok({ email, id: 'acct_123' })
298
168
  ```
299
169
 
300
- ## Core Model
301
-
302
- `Result<T, E>` is a synchronous fallible value. It is either an `Ok<T>` or an `Err<E>`.
170
+ Compose the steps with `andThen`. The resulting error type is inferred as a union:
303
171
 
304
172
  ```ts
305
- const result = parsePort('3000')
173
+ type CreateAccountError = InvalidEmailError | AccountAlreadyExistsError
306
174
 
307
- if (result.isOk()) {
308
- result.value
309
- }
310
-
311
- if (result.isErr()) {
312
- result.error
313
- }
175
+ const createAccount = (input: string): Result<Account, CreateAccountError> =>
176
+ normalizeEmail(input).andThen(ensureAccountIsAvailable).andThen(buildAccount)
314
177
  ```
315
178
 
316
- `ResultAsync<T, E>` is the async version. It wraps promise-based fallible work while keeping the
317
- same composition API.
179
+ Inspect a result directly when local branching is clearer:
318
180
 
319
181
  ```ts
320
- import { createTaggedError, fromPromise, ok, tryResultAsync } from 'resultar'
321
- import type { StrictResult, StrictResultAsync } from 'resultar'
182
+ const account = createAccount('person@example.com')
322
183
 
323
- type User = {
324
- readonly id: string
184
+ if (account.isOk()) {
185
+ account.value.email
325
186
  }
326
187
 
327
- class FetchUserError extends createTaggedError({
328
- name: 'FetchUserError',
329
- message: 'Failed to fetch user $id',
330
- }) {}
331
-
332
- class UserResponseError extends createTaggedError({
333
- name: 'UserResponseError',
334
- message: 'User request $id failed with status $status',
335
- }) {}
336
-
337
- class ParseUserError extends createTaggedError({
338
- name: 'ParseUserError',
339
- message: 'Failed to parse user $id response',
340
- }) {}
341
-
342
- class DecodeUserError extends createTaggedError({
343
- name: 'DecodeUserError',
344
- message: 'Invalid user $id response payload',
345
- }) {}
346
-
347
- const decodeUser = (input: unknown, id: string): StrictResult<User, DecodeUserError> => {
348
- if (
349
- typeof input === 'object' &&
350
- input !== null &&
351
- 'id' in input &&
352
- typeof input.id === 'string'
353
- ) {
354
- return ok({ id: input.id })
355
- }
356
-
357
- return DecodeUserError.err({ id })
188
+ if (account.isErr()) {
189
+ account.error.message
358
190
  }
359
-
360
- const fetchUser = (
361
- id: string,
362
- ): StrictResultAsync<User, FetchUserError | UserResponseError | ParseUserError | DecodeUserError> =>
363
- fromPromise(fetch(`https://example.com/users/${id}`), (cause) => new FetchUserError({ cause, id }))
364
- .andThen((response) =>
365
- response.ok ? ok(response) : UserResponseError.err({ id, status: response.status }),
366
- )
367
- .andThen((response) =>
368
- tryResultAsync<unknown, ParseUserError>(
369
- () => response.json(),
370
- (cause) => new ParseUserError({ cause, id }),
371
- ),
372
- )
373
- .andThen((input) => decodeUser(input, id))
374
-
375
- const label = await fetchUser('usr_123').match(
376
- (user) => `User ${user.id}`,
377
- (error) => error.message,
378
- )
379
191
  ```
380
192
 
381
- This keeps network failures, non-2xx responses, JSON parse failures, and payload decode failures
382
- separate. In production, treat parsing bytes into `unknown` and decoding `unknown` into a domain
383
- type as different failure modes.
384
-
385
- Use generic `Result<T, E>` for local flows where strings, enums, or small domain objects are useful
386
- as `E`. Prefer `StrictResult<T, E extends Error>` and
387
- `StrictResultAsync<T, E extends Error>` at service, HTTP, job, queue, CLI, and integration
388
- boundaries.
389
-
390
- ## Tagged Errors
391
-
392
- `createTaggedError` creates real `Error` subclasses with stable tags and typed message-template
393
- props.
193
+ Or consume both outcomes with `match`:
394
194
 
395
195
  ```ts
396
- import { createTaggedError } from 'resultar'
397
-
398
- class UserNotFoundError extends createTaggedError({
399
- name: 'UserNotFoundError',
400
- message: 'User $id not found in $source',
401
- }) {}
402
-
403
- const error = new UserNotFoundError({
404
- id: 'usr_123',
405
- source: 'database',
406
- })
407
-
408
- error instanceof Error // true
409
- error instanceof UserNotFoundError // true
410
- error._tag // 'UserNotFoundError'
411
- error.message // 'User usr_123 not found in database'
412
- error.id // string | number
413
- error.source // string | number
196
+ const label = createAccount('person@example.com').match(
197
+ (created) => `Created ${created.id}`,
198
+ (error) => `Could not create account: ${error.message}`,
199
+ )
414
200
  ```
415
201
 
416
- Tagged errors include:
202
+ ## Compose Results
417
203
 
418
- - `_tag`
419
- - `messageTemplate`
420
- - `fingerprint`
421
- - `cause`
422
- - `.toJSON()`
423
- - `.findCause(ErrorClass)`
424
- - static `.is(value)` for nominal checks
425
- - static `.err(props)` for returning `Err`
204
+ Resultar composition keeps success and failure channels separate.
426
205
 
427
- Template variables are inferred from `$variables` in the message. Missing variables are TypeScript
428
- errors.
206
+ ### Transform success with `map`
429
207
 
430
208
  ```ts
431
- new UserNotFoundError({ id: 'usr_123', source: 'database' })
432
-
433
- // @ts-expect-error source is required
434
- new UserNotFoundError({ id: 'usr_123' })
209
+ const accountId = createAccount('person@example.com').map((account) => account.id)
435
210
  ```
436
211
 
437
- Use `TaggedEnum` only for lightweight tagged unions that do not need to be real errors.
212
+ ### Add a fallible condition with `filterOrElse`
438
213
 
439
214
  ```ts
440
- import { taggedEnum } from 'resultar'
441
- import type { TaggedEnum } from 'resultar'
442
-
443
- type PaymentError = TaggedEnum<{
444
- CardDeclined: { readonly code: string }
445
- InsufficientFunds: { readonly balance: number }
446
- }>
447
-
448
- const PaymentError = taggedEnum<{
449
- CardDeclined: { readonly code: string }
450
- InsufficientFunds: { readonly balance: number }
451
- }>()
452
-
453
- const declined = PaymentError.CardDeclined({ code: 'card_declined' })
215
+ class UnsupportedEmailDomainError extends createTaggedError({
216
+ name: 'UnsupportedEmailDomainError',
217
+ message: 'Email domain $domain is not supported',
218
+ }) {}
454
219
 
455
- PaymentError.$is('CardDeclined', declined) // true
456
- PaymentError.$match(declined, {
457
- CardDeclined: (error) => error.code,
458
- InsufficientFunds: (error) => String(error.balance),
459
- })
220
+ const requireCompanyEmail = (
221
+ email: string,
222
+ ): Result<string, InvalidEmailError | UnsupportedEmailDomainError> =>
223
+ normalizeEmail(email).filterOrElse(
224
+ (normalized) => normalized.endsWith('@company.com'),
225
+ (normalized) =>
226
+ new UnsupportedEmailDomainError({ domain: normalized.split('@')[1] ?? 'unknown' }),
227
+ )
460
228
  ```
461
229
 
462
- Use `redact(value, label?)` for tagged error props that must not leak through messages or JSON.
463
- Use `revealRedacted(value)` only at the exact boundary that is allowed to see the secret.
464
-
465
- ## Composing Results
466
-
467
- Use `map` for infallible transforms:
230
+ ### Continue with fallible work using `andThen`
468
231
 
469
232
  ```ts
470
- const normalized = validateEmail('PERSON@EXAMPLE.COM').map((email) => email.toLowerCase())
233
+ const createCompanyAccount = (input: string) =>
234
+ requireCompanyEmail(input).andThen(ensureAccountIsAvailable).andThen(buildAccount)
471
235
  ```
472
236
 
473
- Use `as` when success matters but the previous value does not:
237
+ ### Transform a failure with `mapErr`
474
238
 
475
239
  ```ts
476
- const created = insertUser(user).as({ status: 'created' as const })
477
- ```
478
-
479
- Use `andThen` when the next step can fail:
240
+ class AccountServiceError extends createTaggedError({
241
+ name: 'AccountServiceError',
242
+ message: 'Account service failed for $email',
243
+ }) {}
480
244
 
481
- ```ts
482
- const user = validateEmail(email).andThen(ensureUserDoesNotExist).andThen(insertUser)
245
+ const account = createAccount(input).mapErr(
246
+ (cause) => new AccountServiceError({ cause, email: input }),
247
+ )
483
248
  ```
484
249
 
485
- Use `asyncAndThen` when a sync result continues into async work:
250
+ ### Recover with `orElse`
486
251
 
487
252
  ```ts
488
- const user = validateEmail(email).asyncAndThen(ensureUserDoesNotExistAsync).andThen(insertUserAsync)
253
+ const account = findAccountByEmail(email).orElse(() => createAccount(email))
489
254
  ```
490
255
 
491
- Use `filterOrElse` when an `Ok` value must satisfy another predicate:
256
+ Use `pipe` when a result transform should be named and reused:
492
257
 
493
258
  ```ts
494
- class InvalidDomainError extends createTaggedError({
495
- name: 'InvalidDomainError',
496
- message: 'Invalid email domain $domain',
497
- }) {}
259
+ import type { Result } from 'resultar'
498
260
 
499
- const validateCompanyEmail = (
500
- email: string,
501
- ): StrictResult<string, InvalidEmailError | InvalidDomainError> =>
502
- validateEmail(email).filterOrElse(
503
- (validEmail) => validEmail.endsWith('@company.com'),
504
- (validEmail) => new InvalidDomainError({ domain: validEmail.split('@')[1] ?? 'unknown' }),
505
- )
506
- ```
507
-
508
- Use `pipe` when a transform should be named and reused. A pipe step receives the current
509
- `Result`/`ResultAsync` and returns the next value in the chain:
510
-
511
- ```ts
512
- import type { Result, StrictResult } from 'resultar'
261
+ const auditAccount = <E>(result: Result<Account, E>): Result<Account, E> =>
262
+ result.tap((account) => logger.info({ accountId: account.id }, 'account ready'))
513
263
 
514
- const normalizeEmail = <E>(result: Result<string, E>): Result<string, E> =>
515
- result.map((email) => email.trim().toLowerCase())
264
+ const accountEmail = createAccount(input)
265
+ .pipe(auditAccount)
266
+ .map((account) => account.email)
267
+ ```
516
268
 
517
- const requireCompanyDomain = (
518
- result: Result<string, InvalidEmailError>,
519
- ): StrictResult<string, InvalidEmailError | InvalidDomainError> =>
520
- result.filterOrElse(
521
- (email) => email.endsWith('@company.com'),
522
- (email) => new InvalidDomainError({ domain: email.split('@')[1] ?? 'unknown' }),
523
- )
269
+ Callbacks passed to transform methods are normal application code. Resultar does not silently catch
270
+ exceptions thrown by `map`, `andThen`, or `pipe` callbacks. Wrap uncontrolled code at the edge
271
+ instead.
524
272
 
525
- const email = validateEmail(input).pipe(normalizeEmail, requireCompanyDomain)
526
- ```
273
+ ## Tagged Errors
527
274
 
528
- The same pattern works for async results. `pipe` is just composition; it does not catch thrown
529
- errors from the pipe callbacks:
275
+ `createTaggedError` produces real `Error` subclasses with stable tags and structured metadata:
530
276
 
531
277
  ```ts
532
- import type { ResultAsync } from 'resultar'
533
-
534
- const auditUser =
535
- <E>(result: ResultAsync<User, E>): ResultAsync<User, E> =>
536
- result.tap((user) => logger.info({ userId: user.id }, 'user created'))
278
+ const error = new AccountAlreadyExistsError({ email: 'person@example.com' })
537
279
 
538
- const createdEmail = validateEmail(input)
539
- .asyncAndThen(ensureUserDoesNotExistAsync)
540
- .andThen(insertUserAsync)
541
- .pipe(auditUser, (result) => result.map((user) => user.email))
280
+ error instanceof Error // true
281
+ error instanceof AccountAlreadyExistsError // true
282
+ error._tag // 'AccountAlreadyExistsError'
283
+ error.message // 'Account person@example.com already exists'
284
+ error.email // string | number
285
+ error.fingerprint // stable error fingerprint
286
+ error.toJSON() // serializable metadata
542
287
  ```
543
288
 
544
- Use `catchTag` or `catchTags` for local recovery from tagged errors:
545
-
546
- ```ts
547
- const draftUser = createUser('bad-email').catchTag('InvalidEmailError', (error) =>
548
- ok({
549
- email: error.email,
550
- id: 'draft_user',
551
- }),
552
- )
553
- ```
289
+ Tagged errors support:
554
290
 
555
- ## Reasoned Tagged Errors
291
+ - `_tag` for exhaustive handling;
292
+ - typed message-template props;
293
+ - `cause` and stack traces;
294
+ - `fingerprint` and `messageTemplate`;
295
+ - cycle-safe `.toJSON()` output;
296
+ - `.findCause(ErrorClass)` for nested causes;
297
+ - static `.is(value)` for nominal checks;
298
+ - static `.err(props)` for returning an `Err` directly.
556
299
 
557
- Use `catchReason`, `catchReasons`, and `unwrapReason` when a stable parent error has a nested tagged
558
- reason. This keeps the outer error useful for logs and boundaries while allowing local recovery by
559
- the precise reason.
300
+ Preserve the original failure as `cause` when translating infrastructure errors:
560
301
 
561
302
  ```ts
562
- import { createTaggedError, err, ok, taggedEnum } from 'resultar'
563
- import type { StrictResult, TaggedEnum } from 'resultar'
564
-
565
- const AiReason = taggedEnum<{
566
- QuotaExceededError: { readonly limit: number }
567
- RateLimitError: { readonly retryAfterMs: number }
568
- }>()
569
-
570
- type AiReason = TaggedEnum<{
571
- QuotaExceededError: { readonly limit: number }
572
- RateLimitError: { readonly retryAfterMs: number }
573
- }>
574
-
575
- class AiError extends createTaggedError({
576
- name: 'AiError',
577
- message: 'AI request failed',
578
- }) {
579
- public readonly reason: AiReason
580
-
581
- public constructor(props: { readonly cause?: unknown; readonly reason: AiReason }) {
582
- super({ cause: props.cause })
583
- this.reason = props.reason
584
- }
585
- }
303
+ class DatabaseError extends createTaggedError({
304
+ name: 'DatabaseError',
305
+ message: 'Database $operation failed',
306
+ }) {}
307
+
308
+ const databaseError = new DatabaseError({
309
+ cause: new Error('connection refused'),
310
+ operation: 'insert-account',
311
+ })
586
312
 
587
- const askAi = (): StrictResult<string, AiError> =>
588
- err(new AiError({ reason: AiReason.RateLimitError({ retryAfterMs: 1_000 }) }))
313
+ databaseError.findCause(Error)
589
314
  ```
590
315
 
316
+ Use `redact` when metadata must remain available to authorized code without leaking through error
317
+ messages or JSON:
318
+
591
319
  ```ts
592
- const recovered = askAi().catchReason('AiError', 'RateLimitError', (reason) =>
593
- ok(`retry after ${reason.retryAfterMs}ms`),
594
- )
320
+ import { createTaggedError, isRedacted, redact, revealRedacted } from 'resultar'
595
321
 
596
- const unwrapped = askAi().unwrapReason('AiError')
597
- ```
322
+ class TokenRejectedError extends createTaggedError({
323
+ name: 'TokenRejectedError',
324
+ message: 'Token $token was rejected',
325
+ }) {}
598
326
 
599
- `catchReason` removes only the handled reason from the remaining error type. `unwrapReason` moves the
600
- reason union into the error channel when the parent tag matches.
327
+ const error = new TokenRejectedError({
328
+ token: redact('secret-token', 'api-token'),
329
+ })
601
330
 
602
- Use `match`, `matchTags`, or `matchTagsPartial` at boundaries where a result becomes a response,
603
- log entry, queue acknowledgement, CLI exit code, or UI state.
331
+ error.message // 'Token <redacted:api-token> was rejected'
604
332
 
605
- ```ts
606
- const response = createUser(input.email).matchTagsPartial(
607
- (user) => ({ body: user, statusCode: 201 }),
608
- {
609
- InvalidEmailError: (error) => ({
610
- body: { code: error._tag, message: error.message },
611
- statusCode: 400,
612
- }),
613
- },
614
- (error) => ({
615
- body: { code: 'UnhandledError', message: error.message },
616
- statusCode: 500,
617
- }),
618
- )
333
+ if (isRedacted(error.token)) {
334
+ revealRedacted(error.token) // 'secret-token'
335
+ }
619
336
  ```
620
337
 
621
- ## Fallbacks
338
+ Use `taggedEnum` for lightweight tagged states or nested reasons that do not need to be real
339
+ `Error` instances. See the
340
+ [tagged enum guide](https://github.com/inaiat/resultar/blob/main/DOCUMENTATION.md#tagged-enums)
341
+ for the complete API.
622
342
 
623
- Use fallback APIs when an expected failure has a useful recovery path.
343
+ ## Async Work
624
344
 
625
- | Fallback idea | Resultar API |
626
- | --- | --- |
627
- | try another branch | `result.orElse(() => fallbackResult)` |
628
- | replace the failure | `mapErr(...)` or `orElse(() => err(newError))` |
629
- | replace the failure with success | `orElse(() => ok(defaultValue))` inside a pipeline |
630
- | default at the edge | `unwrapOr(defaultValue)` |
631
- | try ordered candidates | `Result.firstSuccessOf` or `ResultAsync.firstSuccessOf` |
345
+ `ResultAsync<T, E>` is the async counterpart to `Result<T, E>`. It is awaitable and exposes the same
346
+ composition style.
632
347
 
633
- Use `orElse` for pipeline recovery:
348
+ Map rejections from external code into a documented error type:
634
349
 
635
350
  ```ts
636
- const user = readUserFromCache(id).orElse(() => readUserFromRemote(id))
637
- ```
351
+ import { tryResultAsync } from 'resultar'
352
+ import type { ResultAsync } from 'resultar'
638
353
 
639
- Use `mapErr` when a low-level failure should become a domain failure:
354
+ class DatabaseError extends createTaggedError({
355
+ name: 'DatabaseError',
356
+ message: 'Database $operation failed',
357
+ }) {}
640
358
 
641
- ```ts
642
- const user = readUserFromRemote(id).mapErr(
643
- (cause) => new ReadUserError({ cause, id }),
644
- )
359
+ const persistAccount = (email: string): ResultAsync<Account, DatabaseError> =>
360
+ tryResultAsync(
361
+ () => accountRepository.insert({ email }),
362
+ (cause) => new DatabaseError({ cause, operation: 'insert-account' }),
363
+ )
645
364
  ```
646
365
 
647
- Use `orElse(() => ok(defaultValue))` when fallback success should stay inside the pipeline:
366
+ Continue from synchronous validation into async work with `asyncAndThen`:
648
367
 
649
368
  ```ts
650
- const user = readUser(id).orElse(() =>
651
- ok({
652
- email: 'guest@example.com',
653
- id: 'guest',
654
- }),
655
- )
369
+ type CreateAccountAsyncError =
370
+ | InvalidEmailError
371
+ | AccountAlreadyExistsError
372
+ | DatabaseError
373
+
374
+ const createAccountAsync = (
375
+ input: string,
376
+ ): ResultAsync<Account, CreateAccountAsyncError> =>
377
+ normalizeEmail(input).andThen(ensureAccountIsAvailable).asyncAndThen(persistAccount)
656
378
  ```
657
379
 
658
- Use `firstSuccessOf` when each fallback should run only if earlier candidates fail:
380
+ Once a workflow is already asynchronous, `andThen` accepts callbacks that return either `Result` or
381
+ `ResultAsync`:
659
382
 
660
383
  ```ts
661
- const user = ResultAsync.firstSuccessOf([
662
- () => readUserFromCache(id),
663
- () => readUserFromPrimary(id),
664
- () => readUserFromReplica(id),
665
- ])
384
+ const provisioned = createAccountAsync(input)
385
+ .andThen(assignDefaultPlan)
386
+ .andThen(sendWelcomeEmail)
387
+ .map((account) => ({ account, status: 'ready' as const }))
666
388
  ```
667
389
 
668
- Use `combine` with records when independent results should keep names:
390
+ Choose the wrapper that matches the external boundary:
669
391
 
670
- ```ts
671
- const context = Result.combine({
672
- org: readOrg(orgId),
673
- user: readUser(userId),
674
- })
392
+ | External work | Use |
393
+ | --- | --- |
394
+ | Run throwing synchronous code now | `tryResult(fn, toError)` |
395
+ | Wrap a throwing synchronous function | `fromThrowable(fn, toError)` |
396
+ | Adapt an existing promise | `fromPromise(promise, toError)` |
397
+ | Run a promise or async factory | `tryResultAsync(factory, toError)` |
398
+ | Wrap an async function | `fromThrowableAsync(fn, toError)` |
399
+ | Adapt a callback or subscription | `ResultAsync.fromCallback(options)` |
675
400
 
676
- const validated = Result.combineWithAllErrors({
677
- email: validateEmail(input.email),
678
- name: validateName(input.name),
679
- })
680
- ```
401
+ Prefer a factory with `tryResultAsync` when creating the promise can also throw synchronously.
402
+
403
+ ## Production Async Policies
681
404
 
682
- For lazy async traversal, `ResultAsync.forEach` and mapped `ResultAsync.validateAll` run
683
- sequentially by default. Pass `{ concurrency: n }` for bounded concurrency or
684
- `{ concurrency: "unbounded" }` to start every mapped task immediately. See
685
- [DOCUMENTATION.md](../../DOCUMENTATION.md#async-concurrency-mapping) for async concurrency details
686
- and the runtime features Resultar intentionally keeps out of core.
405
+ Resultar models common resilience policy in the expected error channel. These helpers use lazy tasks
406
+ so retries can start fresh work and racing helpers can pass a cooperative abort signal.
687
407
 
688
- For concurrent async races, use lazy signal-aware tasks. `race` and `raceAll` return the first
689
- success. `raceFirst` returns the first completed result, success or failure. `timeout` is a
690
- `raceFirst` convenience.
408
+ ### Timeout stays typed
691
409
 
692
410
  ```ts
693
- const user = ResultAsync.timeout(
694
- (signal) => fetchUser(id, { signal }),
411
+ class AccountTimeoutError extends createTaggedError({
412
+ name: 'AccountTimeoutError',
413
+ message: 'Account $accountId timed out after $timeoutMs milliseconds',
414
+ }) {}
415
+
416
+ const account = ResultAsync.timeout(
417
+ (signal) => loadAccount(accountId, { signal }),
695
418
  {
696
- timeoutMs: 2_000,
697
- onTimeout: () => new FetchUserTimeoutError({ id }),
419
+ timeoutMs: 1_500,
420
+ onTimeout: () => new AccountTimeoutError({ accountId, timeoutMs: 1_500 }),
698
421
  },
699
422
  )
700
423
  ```
701
424
 
702
- For transient async failures, use lazy retry tasks. The task receives a zero-based attempt number and
703
- a `ResultAsyncAbortSignal`-compatible signal; `times` is the number of retries after the first
704
- attempt.
425
+ If the timer wins, the timeout is returned as `Err<AccountTimeoutError>` and the task receives an
426
+ abort signal. No rejected timeout promise is introduced.
427
+
428
+ ### Retry transient failures
705
429
 
706
430
  ```ts
707
- const user = ResultAsync.retry(
708
- (attempt, signal) => fetchUser(id, { attempt, signal }),
431
+ const account = ResultAsync.retry(
432
+ (attempt, signal) => loadAccount(accountId, { attempt, signal }),
709
433
  {
710
434
  times: 2,
711
435
  delayMs: ({ nextAttempt }) => nextAttempt * 100,
712
436
  jittered: 0.2,
713
- while: (error) => error._tag === 'RateLimitError',
437
+ while: (error) => error._tag === 'DatabaseBusyError',
714
438
  },
715
439
  )
440
+ ```
716
441
 
717
- const withFallback = ResultAsync.retryOrElse(
718
- (attempt, signal) => fetchUser(id, { attempt, signal }),
442
+ `times` is the number of retries after the first attempt. Use `retryOrElse` when retry exhaustion
443
+ should continue into an explicit fallback:
444
+
445
+ ```ts
446
+ const account = ResultAsync.retryOrElse(
447
+ (attempt, signal) => loadAccount(accountId, { attempt, signal }),
719
448
  {
720
449
  times: 2,
721
- orElse: () => readCachedUser(id),
450
+ orElse: () => loadCachedAccount(accountId),
722
451
  },
723
452
  )
724
453
  ```
725
454
 
726
- Use `jittered` when many callers may retry at the same time. The value is a delay spread factor:
727
- `jittered: 0.2` applies a random delay from `80%` to `120%` of the computed `delayMs`.
728
-
729
- For resourceful async work, use `ResultAsync.withResource` when acquisition and cleanup must stay
730
- paired. Cleanup is best-effort and always runs after successful acquisition, including when the use
731
- step returns `Err` or rejects.
455
+ ### Race equivalent providers
732
456
 
733
457
  ```ts
734
- const lines = ResultAsync.withResource({
735
- acquire: (signal) => openFile(path, { signal }),
736
- use: (file, signal) => readLines(file, { signal }),
737
- release: (file) => file.close(),
738
- })
458
+ const account = ResultAsync.race(
459
+ (signal) => loadAccountFromPrimary(accountId, { signal }),
460
+ (signal) => loadAccountFromReplica(accountId, { signal }),
461
+ )
739
462
  ```
740
463
 
741
- For pull-based streams, keep the runtime native: expose an `AsyncIterable<Result<T, E>>` and close
742
- resources in `finally`. See
743
- [DOCUMENTATION.md](../../DOCUMENTATION.md#resourceful-async-iterables) for the full recipe.
744
-
745
- Use `catchTag` / `catchTags` when recovery depends on a specific tagged error. Use
746
- `matchTagsPartial` when only a boundary needs fallback mapping. Use `unwrapOr` only at final edges
747
- where defaulting is intentional.
464
+ `race` returns the first success and keeps waiting after an early failure. Use `raceFirst` when the
465
+ first completed success or failure should win, or `raceAll` for several equivalent providers.
748
466
 
749
- ## Catching Errors
750
-
751
- Resultar has different catch APIs for different error sources. Use the smallest API that matches
752
- where the failure appears.
753
-
754
- | Need | Use |
755
- | --- | --- |
756
- | Catch thrown sync code now | `tryResult(fn, toError?)` |
757
- | Wrap a throwing sync function for later | `fromThrowable(fn, toError?)` |
758
- | Catch an existing rejecting promise | `fromPromise(promise, toError)` |
759
- | Catch a promise or async factory | `tryResultAsync(promiseOrFactory, toError?)` |
760
- | Wrap an async function for later | `fromThrowableAsync(fn, toError?)` |
761
- | Turn one error into another | `mapErr(fn)` |
762
- | Recover with another result | `orElse(fn)` |
763
- | Recover specific tagged errors | `catchTag(tag, fn)` or `catchTags(handlers)` |
764
- | Recover nested tagged reasons | `catchReason(parentTag, reasonTag, fn)` or `catchReasons(parentTag, handlers)` |
765
- | Convert a result at a boundary | `match`, `matchTags`, or `matchTagsPartial` |
766
- | Handle an existing `Error` value | `matchError` or `matchErrorPartial` |
767
- | Map thrown errors in linear code | `safeTry({ try, catch })` |
768
- | Recover after retry exhaustion | `ResultAsync.retryOrElse(task, options)` |
769
-
770
- Catch uncontrolled code at the edge:
467
+ ### Bound concurrent work
771
468
 
772
469
  ```ts
773
- const parsed: StrictResult<Config, ParseConfigError> = tryResult(
774
- () => JSON.parse(input) as Config,
775
- (cause) => new ParseConfigError({ cause }),
776
- )
777
-
778
- const response: StrictResultAsync<Response, FetchPayloadError> = tryResultAsync(
779
- () => fetch(url),
780
- (cause) => new FetchPayloadError({ cause, url }),
470
+ const imported = ResultAsync.forEach(
471
+ accountInputs,
472
+ (input) => createAccountAsync(input.email),
473
+ { concurrency: 8, discard: true },
781
474
  )
782
475
  ```
783
476
 
784
- Thrown or rejected values are `unknown` at the boundary. The mapper decides the documented error
785
- type: `ParseConfigError` for parsing and `FetchPayloadError` for fetching.
477
+ `forEach` is sequential by default and stops scheduling new work after the first `Err`. Pass a
478
+ positive concurrency number for bounded work or `"unbounded"` to start every mapped task.
786
479
 
787
- Recover inside the pipeline:
480
+ Use mapped `validateAll` when every independent item should run and all failures should be returned:
788
481
 
789
482
  ```ts
790
- const user: StrictResultAsync<User, ReadUserError> = readUserFromCache(id)
791
- .orElse(() => readUserFromDatabase(id))
792
- .mapErr((cause) => new ReadUserError({ cause, id }))
483
+ const validated = ResultAsync.validateAll(
484
+ accountInputs,
485
+ (input) => normalizeEmail(input.email).asyncMap(async (email) => email),
486
+ { concurrency: 8 },
487
+ )
793
488
  ```
794
489
 
795
- The cache/database error union is normalized into one public `ReadUserError` while preserving the
796
- original error as `cause`.
797
-
798
- Handle tagged errors at the boundary:
490
+ ### Pair acquisition with cleanup
799
491
 
800
492
  ```ts
801
- const response: HttpResponse = createUser(input).matchTagsPartial(
802
- (user) => ({ body: user, statusCode: 201 }),
803
- {
804
- InvalidEmailError: (error) => ({ body: { code: error._tag }, statusCode: 400 }),
805
- },
806
- (error) => ({ body: { code: error._tag }, statusCode: 500 }),
807
- )
493
+ const account = ResultAsync.withResource({
494
+ acquire: (signal) => databasePool.connect({ signal }),
495
+ use: (connection, signal) => connection.insertAccount(input, { signal }),
496
+ release: (connection) => connection.close(),
497
+ })
808
498
  ```
809
499
 
810
- For the full decision map, see
811
- [Catching And Recovering Errors](../../DOCUMENTATION.md#catching-and-recovering-errors).
500
+ After successful acquisition, `release` runs when `use` succeeds, returns `Err`, or rejects. For
501
+ stream-shaped work, use native `AsyncIterable<Result<T, E>>`; Resultar intentionally does not add a
502
+ stream runtime or scheduler.
812
503
 
813
- ## Wrapping Throwing Code
504
+ See the full guides for
505
+ [racing and timeout](https://github.com/inaiat/resultar/blob/main/DOCUMENTATION.md#concurrent-racing-and-timeouts),
506
+ [retry](https://github.com/inaiat/resultar/blob/main/DOCUMENTATION.md#retrying-async-work),
507
+ [concurrency](https://github.com/inaiat/resultar/blob/main/DOCUMENTATION.md#async-concurrency-mapping),
508
+ and [resource cleanup](https://github.com/inaiat/resultar/blob/main/DOCUMENTATION.md#resourceful-async-iterables).
814
509
 
815
- Use `tryResult` and `tryResultAsync` at the edge of uncontrolled code:
816
- JSON parsing, platform APIs, third-party libraries, I/O, and network calls.
510
+ ## Linear Workflows With `safeTry`
511
+
512
+ Use `safeTry` when a longer chain reads better as linear code. `yield*` extracts the `Ok` value and
513
+ short-circuits on the first `Err`.
817
514
 
818
515
  ```ts
819
- import { tryResult } from 'resultar'
516
+ import { safeTry } from 'resultar'
517
+ import type { ResultAsync } from 'resultar'
820
518
 
821
- class ParseConfigError extends createTaggedError({
822
- name: 'ParseConfigError',
823
- message: 'Could not parse config',
824
- }) {}
519
+ const createAccountLinear = (
520
+ input: string,
521
+ ): ResultAsync<Account, CreateAccountAsyncError> =>
522
+ safeTry(async function* () {
523
+ const email = yield* normalizeEmail(input)
524
+ yield* ensureAccountIsAvailable(email)
825
525
 
826
- const parseConfig = (input: string): StrictResult<Config, ParseConfigError> =>
827
- tryResult(
828
- () => JSON.parse(input) as Config,
829
- (cause) => new ParseConfigError({ cause }),
830
- )
526
+ return persistAccount(email)
527
+ })
831
528
  ```
832
529
 
833
- Use `fromPromise` when you already have a promise and need to map rejection into an expected error:
530
+ The object form can map unexpected throws from inside the generator while leaving yielded `Err`
531
+ values unchanged:
834
532
 
835
533
  ```ts
836
- const user = fromPromise(loadUserFromRemote(id), (cause) => new FetchUserError({ cause, id }))
534
+ const account = safeTry({
535
+ async *try() {
536
+ const email = yield* normalizeEmail(input)
537
+ return persistAccount(email)
538
+ },
539
+ catch: (cause) => new AccountServiceError({ cause, email: input }),
540
+ })
837
541
  ```
838
542
 
839
- Use `tryResultAsync` when the async boundary is the thing you want to name and document. The object
840
- form is useful for infrastructure helpers because the task and error mapper stay together:
543
+ Prefer `yield* result`. Compatibility helpers such as `safeUnwrap` are not needed in new workflows.
841
544
 
842
- ```ts
843
- import { createTaggedError, tryResultAsync } from 'resultar'
844
- import type { StrictResultAsync } from 'resultar'
545
+ ## Recover Inside A Workflow
845
546
 
846
- type AsyncTask = () => Promise<void>
547
+ Use recovery methods when a failure has a useful local alternative.
847
548
 
848
- class TaskError extends createTaggedError({
849
- name: 'TaskError',
850
- message: 'Task failed: $label',
851
- }) {}
549
+ Recover from one tagged error with `catchTag`:
852
550
 
853
- const tryTask = (label: string, task: AsyncTask): StrictResultAsync<void, TaskError> =>
854
- tryResultAsync({
855
- try: task,
856
- catch: (cause) => new TaskError({ cause, label }),
857
- })
551
+ ```ts
552
+ const account = createAccountAsync(input).catchTag(
553
+ 'AccountAlreadyExistsError',
554
+ (error) => findAccountByEmail(String(error.email)),
555
+ )
858
556
  ```
859
557
 
860
- After that edge conversion, keep your own domain functions returning `Result` values instead of
861
- throwing expected failures.
862
-
863
- ## Validation Errors
864
-
865
- Keep validation failures as normal tagged errors. Map external validators into a small issue shape
866
- at the adapter boundary.
558
+ Recover several tagged errors with `catchTags`:
867
559
 
868
560
  ```ts
869
- type ValidationIssue = {
870
- readonly message: string
871
- readonly path: string
872
- }
873
-
874
- class ValidationError extends createTaggedError({
875
- name: 'ValidationError',
876
- message: 'Invalid input',
877
- }) {
878
- public readonly issues: readonly ValidationIssue[]
879
-
880
- public constructor(props: {
881
- readonly cause?: unknown
882
- readonly issues: readonly ValidationIssue[]
883
- }) {
884
- super({ cause: props.cause })
885
- this.issues = props.issues
886
- }
887
- }
888
-
889
- const issueFromPath = (path: readonly (number | string)[], message: string): ValidationIssue => ({
890
- message,
891
- path: path.join('.'),
561
+ const account = createAccountAsync(input).catchTags({
562
+ AccountAlreadyExistsError: (error) => findAccountByEmail(String(error.email)),
563
+ InvalidEmailError: (error) =>
564
+ ok({ email: String(error.email), id: 'draft_account' }),
892
565
  })
893
566
  ```
894
567
 
895
- For Zod, Standard Schema, or any other validator, decode at the edge and map the library-specific
896
- issue format into `ValidationIssue[]`. Resultar does not depend on any validator package.
568
+ Unhandled tags remain in the error channel. Use `mapErr` when the failure should be translated and
569
+ `orElse` when recovery should run another Result-producing branch.
897
570
 
898
- ## Unexpected Errors
571
+ ```ts
572
+ const account = loadCachedAccount(accountId)
573
+ .orElse(() => loadAccount(accountId))
574
+ .mapErr((cause) => new AccountServiceError({ cause, email: input }))
575
+ ```
899
576
 
900
- Expected application failures belong in `Err`. Bugs, impossible states, and unrecoverable conditions
901
- should remain normal JavaScript throws or rejected promises.
577
+ Use recovery inside a workflow. At an application boundary, consume the final result with `match`,
578
+ `matchTags`, or `matchTagsPartial`.
902
579
 
903
- Use this boundary rule:
580
+ ## Handle Results At Boundaries
904
581
 
905
- - Convert uncontrolled throws and rejections into expected errors with `tryResult`, `tryResultAsync`,
906
- `fromThrowable`, `fromThrowableAsync`, or `fromPromise`.
907
- - Keep your own recoverable domain failures as `Err`.
908
- - Use `unwrapOrThrow()` only at final edges where turning an `Err` into a thrown error is deliberate.
582
+ Boundary code turns a Result into an HTTP response, queue acknowledgement, CLI exit code, log entry,
583
+ or UI state.
909
584
 
910
- ```ts
911
- const user = await fetchUser('usr_123').unwrapOrThrow()
912
- ```
913
-
914
- Inside `safeTry`, the object form can map thrown bugs from the generator body into an explicit error:
585
+ Use `matchTags` when tagged errors should be handled exhaustively:
915
586
 
916
587
  ```ts
917
- const config = safeTry({
918
- *try() {
919
- const raw = yield* readConfigFile()
920
- return ok(JSON.parse(raw) as Config)
588
+ const response = await createAccountAsync(input).matchTags(
589
+ (account) => ({
590
+ body: account,
591
+ statusCode: 201,
592
+ }),
593
+ {
594
+ AccountAlreadyExistsError: (error) => ({
595
+ body: { code: error._tag, message: error.message },
596
+ statusCode: 409,
597
+ }),
598
+ DatabaseError: (error) => ({
599
+ body: { code: error._tag, message: error.message },
600
+ statusCode: 503,
601
+ }),
602
+ InvalidEmailError: (error) => ({
603
+ body: { code: error._tag, message: error.message },
604
+ statusCode: 400,
605
+ }),
921
606
  },
922
- catch: (cause) => new ParseConfigError({ cause }),
923
- })
607
+ )
924
608
  ```
925
609
 
926
- ## Linear Result Code
927
-
928
- Use `safeTry` when a pipeline reads better as linear code. `yield*` unwraps the `Ok` value and
929
- short-circuits on the first `Err`.
610
+ If a handler is missing, TypeScript reports it. If only selected errors need special handling, use
611
+ `matchTagsPartial` with a fallback. Use plain `match` when the distinction between individual error
612
+ tags does not matter.
930
613
 
931
- ```ts
932
- import { safeTry } from 'resultar'
614
+ Avoid unwrapping in normal application flows. `unwrapOr`, `unwrapOrThrow`, `_unsafeUnwrap`, and
615
+ `_unsafeUnwrapErr` are best reserved for deliberate final boundaries or tests.
933
616
 
934
- const createUser = (
935
- email: string,
936
- ): StrictResult<User, InvalidEmailError | UserAlreadyExistsError> =>
937
- safeTry(function* () {
938
- const validEmail = yield* validateEmail(email)
939
- const availableEmail = yield* ensureUserDoesNotExist(validEmail)
617
+ ## Strict Results At Production Boundaries
940
618
 
941
- return insertUser(availableEmail)
942
- })
943
- ```
619
+ `Result<T, E>` intentionally allows any failure type. Strings, enums, and small objects can be useful
620
+ inside narrow local workflows.
944
621
 
945
- The same `safeTry` helper supports async generators:
622
+ For service, HTTP, job, queue, CLI, and integration boundaries, prefer the Error-only aliases:
946
623
 
947
624
  ```ts
948
- const createUserAsync = (
949
- email: string,
950
- ): StrictResultAsync<User, InvalidEmailError | UserAlreadyExistsError | DatabaseError> =>
951
- safeTry(async function* () {
952
- const validEmail = yield* validateEmail(email)
953
- const availableEmail = yield* ensureUserDoesNotExist(validEmail)
954
-
955
- return insertUserAsync(availableEmail)
956
- })
957
- ```
958
-
959
- The object form can map unexpected thrown errors while leaving yielded `Err` values unchanged:
625
+ import type { StrictResult, StrictResultAsync } from 'resultar'
960
626
 
961
- ```ts
962
- const config = safeTry({
963
- *try() {
964
- const raw = yield* readConfigFile()
627
+ const validateAccount = (
628
+ input: string,
629
+ ): StrictResult<string, InvalidEmailError> => normalizeEmail(input)
965
630
 
966
- return ok(JSON.parse(raw) as Config)
967
- },
968
- catch: (cause) => new ParseConfigError({ cause }),
969
- })
631
+ const provisionAccount = (
632
+ input: string,
633
+ ): StrictResultAsync<Account, CreateAccountAsyncError> => createAccountAsync(input)
970
634
  ```
971
635
 
972
- ## No-Discard Validation
636
+ `StrictResult<T, E extends Error>` and `StrictResultAsync<T, E extends Error>` are type-only aliases.
637
+ They keep the same Resultar API while documenting that failures carry standard `Error` behavior:
638
+ messages, causes, stack traces, and structured metadata.
639
+
640
+ ## Prevent Ignored Results
973
641
 
974
- Resultar values should not be ignored. Use `resultar-check` to run TypeScript 7 and Resultar
975
- diagnostics through one command.
642
+ Result values are useful only when callers handle them. Install `resultar-check` with a project-local
643
+ TypeScript 7+:
976
644
 
977
645
  ```sh
978
- pnpm add -D resultar-check typescript-7@npm:typescript@rc
646
+ pnpm add -D resultar-check "typescript@>=7"
979
647
  ```
980
648
 
981
- Add a check script:
649
+ Use the CLI as the authoritative local and CI check:
982
650
 
983
651
  ```json
984
652
  {
@@ -988,150 +656,89 @@ Add a check script:
988
656
  }
989
657
  ```
990
658
 
991
- `resultar-check` defaults to `tsconfig.json` and runs TypeScript with no emit.
992
-
993
- These fail in the default `must-use` mode:
994
-
995
- ```ts
996
- saveUser(input)
997
- const result = saveUser(input)
998
- ```
999
-
1000
- These are intentional:
1001
-
1002
- ```ts
1003
- return saveUser(input)
1004
- void saveUser(input)
1005
- saveUser(input).match(handleSaved, handleError)
1006
- ```
1007
-
1008
- The default mode is neverthrow-style `must-use`: it also reports assigned `Result` values that are
1009
- only passed around and never consumed with `match`, `unwrapOr`, `_unsafeUnwrap`, `isOk`, `isErr`,
1010
- returned, or explicitly discarded. Use `--mode direct` for the lower-noise expression-only check.
1011
-
1012
- Configure Resultar rules in `tsconfig.json`:
659
+ The CLI runs TypeScript first and then Resultar diagnostics over the same `tsconfig.json`. Configure
660
+ the TypeScript language-server plugin for equivalent feedback while editing:
1013
661
 
1014
662
  ```json
1015
663
  {
1016
664
  "$schema": "./node_modules/resultar-check/schema.json",
1017
665
  "compilerOptions": {
1018
- "plugins": [{ "name": "resultar-check", "noDiscard": "error" }]
666
+ "plugins": [
667
+ {
668
+ "name": "resultar-check",
669
+ "noDiscard": "error"
670
+ }
671
+ ]
1019
672
  }
1020
673
  }
1021
674
  ```
1022
675
 
1023
- The package-local schema provides editor completion and validation for `resultar-check` plugin
1024
- options.
676
+ After the CLI and language server are working, add the Oxlint, ESLint, or Deno Lint adapter only if
677
+ you want optional low-latency syntax feedback. The adapters intentionally expose fewer rules because
678
+ they do not have TypeScript type information.
679
+
680
+ See the
681
+ [`resultar-check` guide](https://github.com/inaiat/resultar/blob/main/packages/check/README.md)
682
+ for all rules, editor setup, ignore patterns, and lint adapter configuration.
683
+
684
+ ## HTTP Request Packages
1025
685
 
1026
- Oxlint is intentionally not part of the Resultar rules path; use it only for general linting if your
1027
- project wants it.
686
+ Resultar's Fetch-first request packages return `ResultAsync` values for request creation, network,
687
+ HTTP status, JSON parsing, validation, and retry failures.
1028
688
 
1029
- For VS Code and Zed setup, use the editor integration section in the `resultar-check` package guide.
689
+ | Package | Use it when | Guide |
690
+ | --- | --- | --- |
691
+ | [`resultar-request`](https://www.npmjs.com/package/resultar-request) | You use a custom validator or decoder | [README](https://github.com/inaiat/resultar/blob/main/packages/request/README.md) |
692
+ | [`resultar-request-typebox`](https://www.npmjs.com/package/resultar-request-typebox) | Your response contract is a TypeBox schema | [README](https://github.com/inaiat/resultar/blob/main/packages/request-typebox/README.md) |
693
+ | [`resultar-request-zod`](https://www.npmjs.com/package/resultar-request-zod) | Your response contract is a Zod schema or transform | [README](https://github.com/inaiat/resultar/blob/main/packages/request-zod/README.md) |
694
+
695
+ The adapters delegate transport, JSON parsing, retries, and error mapping to `resultar-request` and
696
+ re-export its public request types. Install only the core request package and schema adapter your
697
+ service needs.
1030
698
 
1031
699
  ## API Decision Guide
1032
700
 
1033
701
  | Need | Use |
1034
702
  | --- | --- |
1035
- | Create a sync success | `ok(value)` |
1036
- | Create a sync failure | `err(error)` or `ErrorClass.err(props)` |
1037
- | Create an async success | `okAsync(value)` |
1038
- | Create an async failure | `errAsync(error)` |
1039
- | Create an `Ok(undefined)` | `unit()` or `unitAsync()` |
1040
- | Convert throwing sync code | `tryResult` or `fromThrowable` |
1041
- | Convert rejecting async code | `tryResultAsync`, `fromPromise`, or `fromThrowableAsync` |
1042
- | Treat an `Err` as unrecoverable at an edge | `unwrapOrThrow()` |
1043
- | Transform success | `map` or `asyncMap` |
1044
- | Replace success with a known value | `as(value)` |
1045
- | Transform error | `mapErr` |
1046
- | Continue with fallible work | `andThen` or `asyncAndThen` |
1047
- | Recover from failure | `orElse`, `catchTag`, or `catchTags` |
1048
- | Recover from a nested tagged reason | `catchReason` or `catchReasons` |
1049
- | Move a parent error reason into the error channel | `unwrapReason` |
1050
- | Replace failure with another failure | `mapErr` or `orElse(() => err(newError))` |
1051
- | Replace failure with success | `orElse(() => ok(defaultValue))` |
1052
- | Handle a boundary | `match`, `matchTags`, or `matchTagsPartial` |
1053
- | Write linear result code | `safeTry` |
1054
- | Combine two independent results | `Result.zip` or `ResultAsync.zip` |
1055
- | Combine many independent results | `Result.combine` or `ResultAsync.combine` |
1056
- | Collect all validation failures | `validateAll` or `combineWithAllErrors` |
1057
- | Try fallback candidates | `firstSuccessOf` |
1058
- | Bound lazy async traversal | `ResultAsync.forEach` or mapped `ResultAsync.validateAll` with `{ concurrency }` |
1059
- | Race concurrent tasks | `ResultAsync.race`, `raceAll`, `raceFirst`, or `raceWith` |
1060
- | Apply a cooperative timeout | `ResultAsync.timeout` |
1061
- | Retry transient async work | `ResultAsync.retry` or `ResultAsync.retryOrElse` |
1062
- | Pair async acquisition with cleanup | `ResultAsync.withResource` |
1063
- | Process collections sequentially | `loop`, `iterate`, or `forEach` |
1064
- | Observe without changing outcome | `tap`, `tapError`, or `log` |
1065
- | Throw intentionally at a final edge | `unwrapOrThrow()` |
1066
- | Default intentionally at a final edge | `unwrapOr(defaultValue)` |
1067
-
1068
- ## What Stays Out Of Core
1069
-
1070
- Resultar keeps the scope narrow: explicit error values, typed expected failures, and composable
1071
- recovery.
1072
-
1073
- - No generator-based runtime DSL.
1074
- - No schedule engine in core.
1075
- - No config, cache, request resolver, resource, fiber, or service runtime.
1076
- - No raw `Error | T` conversion API.
1077
-
1078
- ## Version 3 Notes
1079
-
1080
- `3.0.0` is a semver-major release:
1081
-
1082
- - `no-discard` tooling moved out of the runtime package and into dedicated packages.
1083
- - TypeScript peer support is now `>=6.0.0`.
1084
- - Deprecated APIs are not part of the current API:
1085
-
1086
- - `Result#finally`
1087
- - `ResultAsync#finally`
1088
- - `ResultAsync#safeUnwrap`
1089
- - `safeTryAsync`
1090
-
1091
- Use `log`, `toDisposable`, `toAsyncDisposable`, and `safeTry(async function* () { ... })` instead.
1092
- `Result#safeUnwrap()` remains available for compatibility, but new code should prefer
1093
- `yield* result`.
1094
-
1095
- `tryCatch` and `tryCatchAsync` remain exported as backward-compatible aliases. Prefer `tryResult`
1096
- and `tryResultAsync` in new code and docs.
1097
-
1098
- ## Documentation
1099
-
1100
- This README is the runtime package entry point. For a full guide with larger examples and the
1101
- complete API map, see the repository documentation.
1102
-
1103
- More focused material:
1104
-
1105
- - [Full guide](https://github.com/inaiat/resultar/blob/main/DOCUMENTATION.md)
1106
- - [Type-safe error handling article, English](https://github.com/inaiat/resultar/blob/main/articles/en/type-safe.md)
1107
- - [Artigo sobre tratamento de erros type-safe, Portuguese](https://github.com/inaiat/resultar/blob/main/articles/pt/type-safe.md)
1108
- - [resultar-check package guide](https://github.com/inaiat/resultar/blob/main/packages/resultar-check/README.md)
1109
-
1110
- ## Repository
1111
-
1112
- This repository is a pnpm workspace:
1113
-
1114
- - `packages/resultar`: Resultar runtime package.
1115
- - `packages/resultar-check`: TypeScript 7 plus Resultar validation.
1116
- - `packages/resultar-lint`: deprecated compatibility wrapper.
1117
- - `packages/resultar-tsgo`: deprecated compatibility wrapper for older installs.
1118
- - `benchmarks`: benchmark package.
1119
- - `examples/resultar`: runnable core Resultar cookbook.
1120
- - `examples/lint`: TypeScript 7 `resultar-check` smoke example.
1121
-
1122
- Common commands:
1123
-
1124
- ```sh
1125
- pnpm install
1126
- pnpm run fmt:check
1127
- pnpm run check
1128
- pnpm test
1129
- pnpm run test:cov
1130
- pnpm run build
1131
- pnpm run check:full
1132
- pnpm run test:examples
1133
- pnpm run bench
1134
- ```
703
+ | Create success or failure | `ok`, `err`, `okAsync`, `errAsync` |
704
+ | Create an `Ok(undefined)` | `unit`, `unitAsync` |
705
+ | Transform success | `map`, `asyncMap`, `as` |
706
+ | Add a fallible condition | `filterOrElse` |
707
+ | Transform failure | `mapErr` |
708
+ | Continue fallible work | `andThen`, `asyncAndThen` |
709
+ | Recover from failure | `orElse`, `catchTag`, `catchTags` |
710
+ | Wrap throwing or rejecting code | `tryResult`, `tryResultAsync`, `fromPromise` |
711
+ | Write linear Result code | `safeTry` |
712
+ | Handle a final boundary | `match`, `matchTags`, `matchTagsPartial` |
713
+ | Combine independent results | `zip`, `combine`, `combineWithAllErrors` |
714
+ | Try ordered fallback candidates | `firstSuccessOf` |
715
+ | Process async collections | `ResultAsync.forEach`, `ResultAsync.validateAll` |
716
+ | Race concurrent tasks | `ResultAsync.race`, `raceAll`, `raceFirst` |
717
+ | Apply a timeout | `ResultAsync.timeout` |
718
+ | Retry transient work | `ResultAsync.retry`, `retryOrElse` |
719
+ | Pair acquisition and cleanup | `ResultAsync.withResource` |
720
+ | Observe without changing the result | `tap`, `tapError`, `log` |
721
+ | Default deliberately at an edge | `unwrapOr` |
722
+ | Throw deliberately at an edge | `unwrapOrThrow` |
723
+
724
+ The
725
+ [complete API map](https://github.com/inaiat/resultar/blob/main/DOCUMENTATION.md#api-map)
726
+ covers overloads, aliases, callback semantics, collection helpers, conditional helpers, tagged
727
+ reasons, disposable results, and compatibility APIs.
728
+
729
+ ## More Documentation
730
+
731
+ - [Full Resultar guide](https://github.com/inaiat/resultar/blob/main/DOCUMENTATION.md)
732
+ - [Runnable core cookbook](https://github.com/inaiat/resultar/tree/main/examples/resultar)
733
+ - [Catching and recovering errors](https://github.com/inaiat/resultar/blob/main/DOCUMENTATION.md#catching-and-recovering-errors)
734
+ - [Safe Try](https://github.com/inaiat/resultar/blob/main/DOCUMENTATION.md#safe-try)
735
+ - [Validation error recipes](https://github.com/inaiat/resultar/blob/main/DOCUMENTATION.md#validation-error-recipes)
736
+ - [Coming from other error-handling styles](https://github.com/inaiat/resultar/blob/main/DOCUMENTATION.md#coming-from-other-styles)
737
+ - [Public exports and compatibility aliases](https://github.com/inaiat/resultar/blob/main/DOCUMENTATION.md#public-entry-point)
738
+ - [Type-safe error handling article](https://github.com/inaiat/resultar/blob/main/articles/en/type-safe.md)
739
+ - [Artigo sobre tratamento de erros type-safe](https://github.com/inaiat/resultar/blob/main/articles/pt/type-safe.md)
740
+
741
+ Resultar is also published on [JSR as `@inaiat/resultar`](https://jsr.io/@inaiat/resultar).
1135
742
 
1136
743
  ## License
1137
744