ts-procedures 6.2.0 → 7.0.0-beta.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.
Files changed (109) hide show
  1. package/README.md +2 -0
  2. package/agent_config/claude-code/skills/ts-procedures/SKILL.md +2 -1
  3. package/agent_config/claude-code/skills/ts-procedures/anti-patterns.md +38 -1
  4. package/agent_config/claude-code/skills/ts-procedures/api-reference.md +215 -3
  5. package/agent_config/claude-code/skills/ts-procedures/patterns.md +60 -2
  6. package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/client.md +1 -1
  7. package/agent_config/copilot/copilot-instructions.md +3 -0
  8. package/agent_config/cursor/cursorrules +3 -0
  9. package/build/client/augment-error-map.test-d.d.ts +10 -0
  10. package/build/client/augment-error-map.test-d.js +14 -0
  11. package/build/client/augment-error-map.test-d.js.map +1 -0
  12. package/build/client/call.d.ts +14 -2
  13. package/build/client/call.js +96 -9
  14. package/build/client/call.js.map +1 -1
  15. package/build/client/call.test.js +50 -1
  16. package/build/client/call.test.js.map +1 -1
  17. package/build/client/classify-error.d.ts +11 -0
  18. package/build/client/classify-error.js +49 -0
  19. package/build/client/classify-error.js.map +1 -0
  20. package/build/client/classify-error.test.d.ts +1 -0
  21. package/build/client/classify-error.test.js +55 -0
  22. package/build/client/classify-error.test.js.map +1 -0
  23. package/build/client/error-dispatch.d.ts +1 -1
  24. package/build/client/error-dispatch.js +1 -1
  25. package/build/client/errors.d.ts +55 -4
  26. package/build/client/errors.js +54 -7
  27. package/build/client/errors.js.map +1 -1
  28. package/build/client/errors.test.js +89 -4
  29. package/build/client/errors.test.js.map +1 -1
  30. package/build/client/fetch-adapter.d.ts +2 -1
  31. package/build/client/fetch-adapter.js +2 -1
  32. package/build/client/fetch-adapter.js.map +1 -1
  33. package/build/client/fetch-adapter.test.js +12 -0
  34. package/build/client/fetch-adapter.test.js.map +1 -1
  35. package/build/client/index.d.ts +5 -3
  36. package/build/client/index.js +15 -3
  37. package/build/client/index.js.map +1 -1
  38. package/build/client/resolve-options.d.ts +32 -1
  39. package/build/client/resolve-options.js +32 -16
  40. package/build/client/resolve-options.js.map +1 -1
  41. package/build/client/resolve-options.test.js +67 -6
  42. package/build/client/resolve-options.test.js.map +1 -1
  43. package/build/client/result-type.test-d.d.ts +1 -0
  44. package/build/client/result-type.test-d.js +28 -0
  45. package/build/client/result-type.test-d.js.map +1 -0
  46. package/build/client/safe-call.test.d.ts +1 -0
  47. package/build/client/safe-call.test.js +137 -0
  48. package/build/client/safe-call.test.js.map +1 -0
  49. package/build/client/stream.d.ts +1 -1
  50. package/build/client/stream.js +22 -8
  51. package/build/client/stream.js.map +1 -1
  52. package/build/client/stream.test.js +11 -1
  53. package/build/client/stream.test.js.map +1 -1
  54. package/build/client/types.d.ts +96 -3
  55. package/build/codegen/bundle-size.test.d.ts +1 -0
  56. package/build/codegen/bundle-size.test.js +68 -0
  57. package/build/codegen/bundle-size.test.js.map +1 -0
  58. package/build/codegen/e2e.test.js +103 -1
  59. package/build/codegen/e2e.test.js.map +1 -1
  60. package/build/codegen/emit-client-runtime.js +7 -0
  61. package/build/codegen/emit-client-runtime.js.map +1 -1
  62. package/build/codegen/emit-client-runtime.test.js +6 -2
  63. package/build/codegen/emit-client-runtime.test.js.map +1 -1
  64. package/build/codegen/emit-client-types.d.ts +7 -2
  65. package/build/codegen/emit-client-types.js +29 -8
  66. package/build/codegen/emit-client-types.js.map +1 -1
  67. package/build/codegen/emit-client-types.test.js +20 -8
  68. package/build/codegen/emit-client-types.test.js.map +1 -1
  69. package/build/codegen/emit-errors.d.ts +1 -1
  70. package/build/codegen/emit-errors.js +1 -1
  71. package/build/codegen/emit-index.js +1 -1
  72. package/build/codegen/emit-index.js.map +1 -1
  73. package/build/codegen/emit-scope.js +94 -26
  74. package/build/codegen/emit-scope.js.map +1 -1
  75. package/build/codegen/emit-scope.test.js +297 -2
  76. package/build/codegen/emit-scope.test.js.map +1 -1
  77. package/docs/client-and-codegen.md +77 -7
  78. package/docs/client-error-handling.md +357 -0
  79. package/docs/superpowers/plans/2026-04-29-safe-result-api.md +2293 -0
  80. package/docs/superpowers/specs/2026-04-29-safe-result-api-design.md +324 -0
  81. package/package.json +1 -1
  82. package/src/client/augment-error-map.test-d.ts +22 -0
  83. package/src/client/call.test.ts +65 -1
  84. package/src/client/call.ts +111 -9
  85. package/src/client/classify-error.test.ts +65 -0
  86. package/src/client/classify-error.ts +59 -0
  87. package/src/client/error-dispatch.ts +1 -1
  88. package/src/client/errors.test.ts +108 -4
  89. package/src/client/errors.ts +70 -7
  90. package/src/client/fetch-adapter.test.ts +15 -0
  91. package/src/client/fetch-adapter.ts +5 -2
  92. package/src/client/index.ts +39 -3
  93. package/src/client/resolve-options.test.ts +83 -5
  94. package/src/client/resolve-options.ts +61 -16
  95. package/src/client/result-type.test-d.ts +51 -0
  96. package/src/client/safe-call.test.ts +157 -0
  97. package/src/client/stream.test.ts +13 -1
  98. package/src/client/stream.ts +25 -8
  99. package/src/client/types.ts +112 -3
  100. package/src/codegen/bundle-size.test.ts +74 -0
  101. package/src/codegen/e2e.test.ts +108 -1
  102. package/src/codegen/emit-client-runtime.test.ts +7 -2
  103. package/src/codegen/emit-client-runtime.ts +7 -0
  104. package/src/codegen/emit-client-types.test.ts +22 -7
  105. package/src/codegen/emit-client-types.ts +35 -10
  106. package/src/codegen/emit-errors.ts +1 -1
  107. package/src/codegen/emit-index.ts +1 -1
  108. package/src/codegen/emit-scope.test.ts +324 -2
  109. package/src/codegen/emit-scope.ts +98 -36
@@ -60,7 +60,7 @@ const api = createClient({
60
60
  adapter: createFetchAdapter(),
61
61
  basePath: 'http://localhost:3000',
62
62
  scopes: createApiBindings,
63
- errorRegistry: ApiErrors.ApiErrorRegistry, // opt in to typed dispatch — omit to get the plain ClientRequestError transport shape
63
+ errorRegistry: ApiErrors.ApiErrorRegistry, // opt in to typed dispatch — omit to get the plain ClientHttpError transport shape
64
64
  })
65
65
  ```
66
66
 
@@ -127,6 +127,19 @@ const axiosAdapter: ClientAdapter = {
127
127
  }
128
128
  ```
129
129
 
130
+ Both `ClientAdapter` and `createFetchAdapter` accept an optional `classifyError` field. When provided, the classifier overrides the default mapping from raw platform errors (`TypeError`, `DOMException`) to framework error classes. Pass a custom `ErrorClassifier` to emit your own `ClientErrorMap` categories.
131
+
132
+ ```typescript
133
+ import { createFetchAdapter, defaultClassifyError } from 'ts-procedures/client'
134
+
135
+ const adapter = createFetchAdapter({
136
+ classifyError: (err, ctx) => {
137
+ // delegate to the built-in classifier for everything except your own cases
138
+ return defaultClassifyError(err, ctx)
139
+ },
140
+ })
141
+ ```
142
+
130
143
  ## Per-Call Options
131
144
 
132
145
  Every generated callable accepts an optional second argument for per-call configuration. The options bag covers both request-level config (timeout, cancellation, headers, base path) and hooks.
@@ -259,8 +272,8 @@ try {
259
272
  // err.body is typed to UseCaseErrorBody; err.status, err.procedureName, err.scope available
260
273
  } else if (err instanceof ApiErrors.ApiProcedureError) {
261
274
  // Catch-all for any generated service error
262
- } else if (err instanceof ClientRequestError) {
263
- // Transport-level error (unmatched body.name, or non-JSON response)
275
+ } else if (err instanceof ClientHttpError) {
276
+ // Transport-level error (unmatched body.name, or non-JSON response) — err.status, err.cause
264
277
  }
265
278
  }
266
279
  ```
@@ -282,7 +295,7 @@ export namespace Users {
282
295
  // Consumer:
283
296
  try { await api.users.GetUser({ pathParams: { id: 'x' } }) }
284
297
  catch (err) {
285
- const e = err as Users.GetUser.Errors | ClientRequestError // TS has no typed-throws; manual annotation
298
+ const e = err as Users.GetUser.Errors | ClientHttpError // TS has no typed-throws; manual annotation
286
299
  if (e instanceof ApiErrors.UseCaseError) { /* ... */ }
287
300
  }
288
301
  ```
@@ -296,10 +309,42 @@ Route-errors that reference keys missing from `_errors.ts` are filtered out at c
296
309
  1. If no registry, or body isn't an object with a string `name`, or no registry key matches → returns `null`.
297
310
  2. Otherwise calls `registry[body.name].fromResponse(body, meta)` and returns the typed `Error` instance.
298
311
 
299
- When the helper returns `null`, the call falls back to `ClientRequestError` — the plain transport-error shape, used for unmatched names, non-JSON bodies, and when no registry is configured.
312
+ When the helper returns `null`, the call falls back to `ClientHttpError` — the plain transport-error shape, used for unmatched names, non-JSON bodies, and when no registry is configured.
300
313
 
301
314
  Stream endpoints get the same treatment for **pre-stream** errors (thrown before the first yield — e.g. validation or auth). The fetch adapter's `stream()` eagerly parses a JSON body when status is non-2xx and exposes it via `AdapterStreamResponse.errorBody`, which `executeStream` passes through the registry. **Mid-stream** SSE error events continue to flow through `onMidStreamError` on the server and re-throw on the client without registry dispatch.
302
315
 
316
+ ### Result-returning `.safe()` form
317
+
318
+ Every generated RPC and API callable has a `.safe()` sibling that returns `Result<T, ETyped>` instead of throwing. This eliminates `try/catch` for callers that prefer exhaustive narrowing via a discriminated union.
319
+
320
+ ```typescript
321
+ const r = await api.users.GetUser.safe({ pathParams: { id: '123' } })
322
+
323
+ if (r.ok) {
324
+ console.log(r.value) // typed as GetUser.Response
325
+ } else {
326
+ switch (r.kind) {
327
+ case 'typed': // registry-dispatched error (instanceof ApiErrors.UseCaseError, etc.)
328
+ console.error(r.error.body)
329
+ break
330
+ case 'http': // ClientHttpError — non-2xx with unmatched or non-JSON body
331
+ console.error(`HTTP ${r.error.status}`)
332
+ break
333
+ case 'network': // ClientNetworkError
334
+ case 'timeout': // ClientTimeoutError
335
+ case 'aborted': // ClientAbortError
336
+ case 'parse': // ClientParseError — response body could not be parsed
337
+ case 'usage': // programming error (e.g., missing path param)
338
+ case 'unknown': // catch-all
339
+ console.error(r.error)
340
+ }
341
+ }
342
+ ```
343
+
344
+ The underlying method is `ClientInstance.safeCall<TResponse, ETyped>(descriptor, options)` — all generated `.safe()` callables delegate to it. Streams keep the throwing form; `.safe()` is intentionally absent on stream procedures.
345
+
346
+ Custom error categories can be added to `r.kind`'s union via `ClientErrorMap` interface augmentation. See [`docs/client-error-handling.md`](./client-error-handling.md) for the full reference, including `Result<T, E>`, `ResultNoTyped<T>`, `FrameworkFailure`, `ErrorClassifier`, and `ClientErrorMap` augmentation patterns.
347
+
303
348
  ## Hooks
304
349
 
305
350
  Hooks let you intercept requests and responses globally or per-procedure call. Global hooks apply to every call made through the client instance; per-procedure hooks run after global ones for a single invocation.
@@ -378,7 +423,7 @@ await generateClient({
378
423
  By default, the generated output includes two additional files in the output directory:
379
424
 
380
425
  - **`_types.ts`** — All client type definitions (`ClientInstance`, `TypedStream`, `ProcedureCallOptions`, hooks, adapters, descriptors)
381
- - **`_client.ts`** — Full client runtime: `createClient`, `createFetchAdapter`, hook pipeline, and error classes (`ClientRequestError`, `ClientPathParamError`, `ClientStreamError`)
426
+ - **`_client.ts`** — Full client runtime: `createClient`, `createFetchAdapter`, hook pipeline, and error classes (`ClientHttpError`, `ClientNetworkError`, `ClientTimeoutError`, `ClientAbortError`, `ClientParseError`, `ClientPathParamError`, `ClientStreamError`)
382
427
 
383
428
  All generated scope files and `index.ts` import from `./_types` instead of `ts-procedures/client`, so app consumers can import everything from the generated directory without needing `ts-procedures` as a runtime dependency. `ts-procedures` becomes a devDependency only.
384
429
 
@@ -396,7 +441,21 @@ import { createClient, createFetchAdapter } from 'ts-procedures/client'
396
441
 
397
442
  ```typescript
398
443
  // Client Runtime
399
- import { createClient, createFetchAdapter, dispatchTypedError } from 'ts-procedures/client'
444
+ import {
445
+ createClient,
446
+ createFetchAdapter,
447
+ dispatchTypedError,
448
+ defaultClassifyError,
449
+ // Error classes (7.0+)
450
+ ClientHttpError, // non-2xx HTTP response (renamed from ClientRequestError)
451
+ ClientNetworkError, // network-level failure (e.g., fetch TypeError)
452
+ ClientTimeoutError, // request timed out via AbortSignal.timeout
453
+ ClientAbortError, // request aborted by caller signal
454
+ ClientParseError, // response body could not be parsed as JSON
455
+ ClientPathParamError, // programming error: path param missing at call time
456
+ ClientStreamError, // stream-level transport error
457
+ } from 'ts-procedures/client'
458
+
400
459
  import type {
401
460
  ClientAdapter,
402
461
  ClientHooks,
@@ -405,8 +464,19 @@ import type {
405
464
  ErrorRegistry,
406
465
  ErrorFactory,
407
466
  ErrorResponseMeta,
467
+ // Safe-result types (7.0+)
468
+ Result, // Result<T, ETyped> — ok branch or failure branch
469
+ ResultNoTyped, // Result<T, never> — when no typed error registry is wired
470
+ FrameworkFailure, // The failure branch of Result (discriminated by `kind`)
471
+ ClientErrorMap, // Augmentable interface for custom error categories
472
+ // Classifier types (7.0+)
473
+ ErrorClassifier,
474
+ ClassifyErrorContext,
475
+ ClassifiedError,
408
476
  } from 'ts-procedures/client'
409
477
 
410
478
  // Code Generation
411
479
  import { generateClient } from 'ts-procedures/codegen'
412
480
  ```
481
+
482
+ > **Migration note:** `ClientRequestError` was renamed to `ClientHttpError` in 7.0.0. The old name is re-exported as a deprecated alias for one minor cycle. Update imports when possible.
@@ -0,0 +1,357 @@
1
+ # Client Error Handling
2
+
3
+ Canonical reference for downstream developers using ts-procedures generated clients to handle errors.
4
+
5
+ ## 1. Two Forms — Pick Either, Mix as Needed
6
+
7
+ Every generated RPC and API callable supports two equivalent call styles.
8
+
9
+ **Throwing form** (the canonical form): the call returns the response directly or throws a typed error class.
10
+
11
+ ```ts
12
+ const response = await client.users.GetUser({ userId })
13
+ ```
14
+
15
+ **`.safe()` form**: the call always resolves (never rejects) and returns a discriminated `Result<T, E>`. Useful when you want exhaustive failure handling with TypeScript narrowing inside an exhaustive `switch`.
16
+
17
+ ```ts
18
+ const r = await client.users.GetUser.safe({ userId })
19
+ if (r.ok) {
20
+ // r.value is typed as the response
21
+ } else {
22
+ // r.kind narrows the failure category
23
+ }
24
+ ```
25
+
26
+ Both forms fire the same hooks, respect the same defaults, and go through the same error registry. You can mix them freely — per call site, use whichever reads most naturally.
27
+
28
+ > Note: `.safe()` is only available on RPC and API callables. Stream callables keep the throwing form — their failure surface (mid-stream errors, backpressure, early disconnection) does not fit cleanly into `Result<T, E>`.
29
+
30
+ ---
31
+
32
+ ## 2. Framework Error Class Taxonomy
33
+
34
+ All framework error classes are exported from `ts-procedures/client` (or from `./_types` in self-contained generated clients).
35
+
36
+ | Class | When it is thrown | Notable properties |
37
+ |---|---|---|
38
+ | `ClientHttpError` | Adapter returned a non-2xx response and no error registry entry matched | `status`, `headers`, `body`, `procedureName`, `scope`, `cause` |
39
+ | `ClientNetworkError` | Adapter threw a `TypeError` (e.g., DNS failure, connection refused) | `procedureName`, `scope`, `cause` |
40
+ | `ClientTimeoutError` | The request was aborted because the timeout signal fired | `procedureName`, `scope`, `timeoutMs`, `cause` |
41
+ | `ClientAbortError` | The request was aborted because a user-supplied signal fired | `procedureName`, `scope`, `reason`, `cause` |
42
+ | `ClientPathParamError` | A required path parameter was missing when building the request | `cause` |
43
+ | `ClientParseError` | Reserved for adapter authors who want stricter response-body parsing | `procedureName`, `scope`, `cause` |
44
+ | `ClientStreamError` | A mid-stream SSE error event was received | `procedureName`, `scope`, `cause` |
45
+
46
+ Every class carries `cause` to surface the underlying platform error — typically a `TypeError` or `DOMException` — when one exists.
47
+
48
+ `ClientRequestError` is a deprecated alias for `ClientHttpError`. It is retained for one minor release after 7.0.0 and will be removed in a subsequent minor. See the [migration guide](#8-migration-6x--7x) below.
49
+
50
+ ---
51
+
52
+ ## 3. Throwing Form: Narrowing Pattern
53
+
54
+ The throwing form is the most common pattern. Catch the error and narrow by class:
55
+
56
+ ```ts
57
+ import { ClientHttpError, ClientTimeoutError, ClientNetworkError, ClientAbortError } from 'ts-procedures/client'
58
+ // ApiErrors is exported from your generated index, e.g.:
59
+ // import { ApiErrors } from './generated'
60
+
61
+ try {
62
+ const response = await client.records.DownloadRecord(
63
+ { body: { recordId } },
64
+ { timeout: 60_000 },
65
+ )
66
+ if (response.pdfBase64) downloadBase64AsPdf(response.pdfBase64)
67
+ } catch (err) {
68
+ if (err instanceof ApiErrors.UseCaseError) {
69
+ // Typed error declared on the route — strongly typed body
70
+ Alerts.error(err.body.message)
71
+ } else if (err instanceof ClientHttpError) {
72
+ Alerts.error(`Server returned ${err.status}`)
73
+ } else if (err instanceof ClientTimeoutError) {
74
+ Alerts.error('Request timed out')
75
+ } else if (err instanceof ClientNetworkError) {
76
+ Alerts.error('Network error — check your connection')
77
+ } else if (err instanceof ClientAbortError) {
78
+ // User cancelled — silent
79
+ } else {
80
+ throw err // Programmer or framework bug — fail loud
81
+ }
82
+ }
83
+ ```
84
+
85
+ `ApiErrors.UseCaseError` is illustrative. The actual namespace is `${ServiceName}Errors` based on your `--service-name` codegen option (default: `ApiErrors`). Each error class name matches the key you declared in your server-side error taxonomy.
86
+
87
+ The `else { throw err }` rethrow at the end is intentional: any error that slips past all your `instanceof` checks is a bug — do not swallow it.
88
+
89
+ ---
90
+
91
+ ## 4. `.safe()` Form: Exhaustive Narrowing
92
+
93
+ The same operation rewritten with `.safe()`. The call never rejects. `r.ok` is the top-level gate; `r.kind` narrows the failure:
94
+
95
+ ```ts
96
+ import { ApiErrors } from './generated'
97
+
98
+ const r = await client.records.DownloadRecord.safe(
99
+ { body: { recordId } },
100
+ { timeout: 60_000 },
101
+ )
102
+
103
+ if (r.ok) {
104
+ if (r.value.pdfBase64) downloadBase64AsPdf(r.value.pdfBase64)
105
+ return
106
+ }
107
+
108
+ switch (r.kind) {
109
+ case 'typed':
110
+ if (r.error instanceof ApiErrors.UseCaseError) Alerts.error(r.error.body.message)
111
+ break
112
+ case 'http':
113
+ Alerts.error(`Server returned ${r.error.status}`)
114
+ break
115
+ case 'network':
116
+ Alerts.error('Network error')
117
+ break
118
+ case 'timeout':
119
+ Alerts.error('Request timed out')
120
+ break
121
+ case 'aborted':
122
+ break // User cancelled — silent
123
+ case 'parse':
124
+ case 'usage':
125
+ throw r.error // Programmer or framework bug — fail loud
126
+ case 'unknown':
127
+ Alerts.error('Unknown error')
128
+ console.error(r.error)
129
+ break
130
+ }
131
+ ```
132
+
133
+ The `Result<T, E>` type is:
134
+
135
+ ```ts
136
+ type Result<T, ETyped> =
137
+ | { ok: true; value: T }
138
+ | { ok: false; kind: 'typed'; error: ETyped } // route-declared, registry-dispatched
139
+ | { ok: false; kind: 'http'; error: ClientHttpError }
140
+ | { ok: false; kind: 'network'; error: ClientNetworkError }
141
+ | { ok: false; kind: 'timeout'; error: ClientTimeoutError }
142
+ | { ok: false; kind: 'aborted'; error: ClientAbortError }
143
+ | { ok: false; kind: 'parse'; error: ClientParseError }
144
+ | { ok: false; kind: 'usage'; error: ClientPathParamError }
145
+ | { ok: false; kind: 'unknown'; error: unknown }
146
+ ```
147
+
148
+ When a route declares no typed errors, the generated callable returns `ResultNoTyped<T>` — the `kind: 'typed'` arm is omitted entirely so IDE hovers stay clean.
149
+
150
+ ---
151
+
152
+ ## 5. The Three Failure-Source Paths
153
+
154
+ Understanding where errors originate helps you know what to expect at each stage:
155
+
156
+ | # | Stage | Source of failure | Resulting kind(s) |
157
+ |---|---|---|---|
158
+ | 1 | **Pre-adapter** | `buildAdapterRequest` throws synchronously (e.g. missing path param) | `'usage'` (`ClientPathParamError`) — bypasses the classifier and `onError` hook |
159
+ | 2 | **Adapter throws** | `adapter.request()` rejects (network failure, abort, or custom) | `'network'` / `'timeout'` / `'aborted'` / custom kinds / `'unknown'` |
160
+ | 3 | **Adapter returns non-2xx** | `response.status < 200` or `>= 300` | `'typed'` (registry match) or `'http'` (no match → `ClientHttpError`) |
161
+
162
+ Stage 1 (`'usage'`) is always a programming error — a required path parameter is absent. These bypass `onError` and the error classifier because the request was never sent.
163
+
164
+ Stage 2 errors go through `adapter.classifyError` (if present) then `defaultClassifyError`. Anything the classifier does not recognise lands as `'unknown'`.
165
+
166
+ Stage 3: when a non-2xx response body has a `name` field that matches an entry in the error registry, the client constructs and throws the matching typed error class. When no entry matches, the client throws `ClientHttpError`.
167
+
168
+ ---
169
+
170
+ ## 6. Custom Error Categories — `ClientErrorMap` Augmentation
171
+
172
+ The `kind` discriminant set is open for extension via TypeScript declaration merging. This lets you teach the type system about custom error kinds produced by your adapter:
173
+
174
+ ```ts
175
+ // Declare the new kind once in your app — usually alongside your adapter setup.
176
+ class RateLimitError extends Error {
177
+ constructor(public retryAfter: number) {
178
+ super('rate limited')
179
+ }
180
+ }
181
+
182
+ // With the standard (non-self-contained) client:
183
+ declare module 'ts-procedures/client' {
184
+ interface ClientErrorMap {
185
+ rateLimited: RateLimitError
186
+ }
187
+ }
188
+ ```
189
+
190
+ For self-contained generated clients, the augmentation target shifts to the bundled types file:
191
+
192
+ ```ts
193
+ // With a self-contained (code-generated) client:
194
+ declare module './generated/_types' {
195
+ interface ClientErrorMap {
196
+ rateLimited: RateLimitError
197
+ }
198
+ }
199
+ ```
200
+
201
+ This is the same pattern as `RequestMeta` augmentation — if you have used that, this is identical.
202
+
203
+ After augmentation, `Result`'s union expands automatically. The new `kind` is exhaustively narrowable in every `.safe()` switch:
204
+
205
+ ```ts
206
+ const r = await client.records.DownloadRecord.safe({ body: { recordId } })
207
+ if (!r.ok && r.kind === 'rateLimited') {
208
+ Alerts.error(`Retry in ${r.error.retryAfter}s`)
209
+ }
210
+ ```
211
+
212
+ **`'typed'` is reserved.** Do not add it to your `ClientErrorMap` augmentation — it is used internally for route-declared errors dispatched via the codegen `errors` key. Adding it produces a TypeScript error about overlapping discriminants.
213
+
214
+ ---
215
+
216
+ ## 7. Adapter-Side Classifier Composition
217
+
218
+ Custom error kinds only flow through the `Result` union if the adapter's classifier produces them. Configure the classifier on `createFetchAdapter`:
219
+
220
+ ```ts
221
+ import { createFetchAdapter, defaultClassifyError } from 'ts-procedures/client'
222
+
223
+ const adapter = createFetchAdapter({
224
+ classifyError: (raw, ctx) => {
225
+ if (raw instanceof MyRateLimitError) {
226
+ return { kind: 'rateLimited', error: raw }
227
+ }
228
+ // Fall through to the built-in classifier for TypeError / DOMException / etc.
229
+ return defaultClassifyError(raw, ctx)
230
+ },
231
+ })
232
+ ```
233
+
234
+ `defaultClassifyError` handles:
235
+ - `TypeError` → `ClientNetworkError` (`kind: 'network'`)
236
+ - `DOMException { name: 'AbortError' }` + timeout signal aborted → `ClientTimeoutError` (`kind: 'timeout'`)
237
+ - `DOMException { name: 'AbortError' }` + user signal aborted → `ClientAbortError` (`kind: 'aborted'`)
238
+
239
+ Composing with `defaultClassifyError` at the end is the recommended pattern. Adapter authors who want to completely replace the default can omit the fallthrough — anything the classifier returns `null` for lands as `kind: 'unknown'`.
240
+
241
+ The classifier is called only when the adapter throws (Stage 2 above). Non-2xx responses go through the error registry path (Stage 3), not the classifier.
242
+
243
+ ---
244
+
245
+ ## 8. Migration: 6.x → 7.x
246
+
247
+ Three breaking changes in 7.0.0 affect error handling.
248
+
249
+ ### 8a. `ClientRequestError` Renamed to `ClientHttpError`
250
+
251
+ `ClientRequestError` is a deprecated alias for `ClientHttpError`, retained for one minor release after 7.0.0.
252
+
253
+ **Action**: rename your imports from `ClientRequestError` to `ClientHttpError`. The `instanceof ClientRequestError` guard continues to work until the alias is removed.
254
+
255
+ ```ts
256
+ // Before
257
+ import { ClientRequestError } from 'ts-procedures/client'
258
+ if (err instanceof ClientRequestError) { ... }
259
+
260
+ // After
261
+ import { ClientHttpError } from 'ts-procedures/client'
262
+ if (err instanceof ClientHttpError) { ... }
263
+ ```
264
+
265
+ ### 8b. Raw `DOMException` / `TypeError` No Longer Reach Consumer Catch Blocks
266
+
267
+ Before 7.0.0, the fetch adapter let platform errors propagate directly. After 7.0.0, all thrown errors from the adapter are normalized through the classifier before reaching your code.
268
+
269
+ ```ts
270
+ // Before 6.x — catching raw platform errors
271
+ try {
272
+ await client.users.GetUser(params)
273
+ } catch (e) {
274
+ if (e instanceof DOMException && e.name === 'AbortError') {
275
+ // handle abort
276
+ }
277
+ }
278
+
279
+ // After 7.x — catching framework error classes
280
+ try {
281
+ await client.users.GetUser(params)
282
+ } catch (e) {
283
+ if (e instanceof ClientTimeoutError) {
284
+ // timed out
285
+ } else if (e instanceof ClientAbortError) {
286
+ // user-cancelled
287
+ }
288
+ }
289
+ ```
290
+
291
+ The original platform error is preserved on `error.cause` if you need to inspect it.
292
+
293
+ ### 8c. `onError` Hook Receives the Normalized Framework Error
294
+
295
+ Before 7.0.0, the `onError` hook saw the raw `DOMException` or `TypeError`. After 7.0.0, the hook sees the normalized framework class (`ClientNetworkError`, `ClientTimeoutError`, etc.), with the original platform error on `.cause`.
296
+
297
+ **Action**: update any hook logic that inspected the raw error shape. Most consumers only call `console.error(error)` or log `error.message` and are unaffected.
298
+
299
+ ---
300
+
301
+ ## 9. FAQ — `.safe()` and Retry Loops
302
+
303
+ **Q: My retry loop using `.safe()` triggers `onError` once per attempt. Is that intentional?**
304
+
305
+ Yes. `onError` fires on every failure regardless of which call style you use. Telemetry consumers want to see all attempts uniformly.
306
+
307
+ To suppress logging on a retry loop, you have two options:
308
+
309
+ **Option 1 — per-call `onError` no-op:**
310
+
311
+ ```ts
312
+ for (let attempt = 0; attempt < 3; attempt++) {
313
+ const r = await client.users.GetUser.safe(params, {
314
+ onError: () => {}, // suppress onError for this call
315
+ })
316
+ if (r.ok) return r.value
317
+ // inspect r.kind to decide whether to retry
318
+ }
319
+ ```
320
+
321
+ **Option 2 — flag in `meta`, check in the global `onError`:**
322
+
323
+ ```ts
324
+ // Augment RequestMeta once in your app:
325
+ declare module 'ts-procedures/client' {
326
+ interface RequestMeta { isRetry?: boolean }
327
+ }
328
+
329
+ // In createClient setup:
330
+ const client = createApiClient({
331
+ basePath: 'https://api.example.com',
332
+ hooks: {
333
+ onError: ({ request, error }) => {
334
+ if (request.meta?.isRetry) return // skip telemetry for retries
335
+ logger.error(error)
336
+ },
337
+ },
338
+ })
339
+
340
+ // At call sites:
341
+ for (let attempt = 0; attempt < 3; attempt++) {
342
+ const r = await client.users.GetUser.safe(params, {
343
+ meta: { isRetry: attempt > 0 },
344
+ })
345
+ if (r.ok) return r.value
346
+ }
347
+ ```
348
+
349
+ A framework-level "skip `onError` for `.safe()`" toggle was deliberately deferred to a future minor release. The workarounds above are sufficient for the common case.
350
+
351
+ ---
352
+
353
+ ## 10. Reference
354
+
355
+ - Design spec: [`docs/superpowers/specs/2026-04-29-safe-result-api-design.md`](./superpowers/specs/2026-04-29-safe-result-api-design.md)
356
+ - Client types and interfaces: `CLAUDE.md` — "Key Files" → `src/client/`
357
+ - Agent config patterns and anti-patterns: `agent_config/claude-code/skills/ts-procedures/patterns.md` and `anti-patterns.md`