ts-procedures 5.15.0 → 6.0.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 (164) hide show
  1. package/README.md +2 -0
  2. package/agent_config/claude-code/agents/ts-procedures-architect.md +13 -6
  3. package/agent_config/claude-code/skills/ts-procedures/SKILL.md +26 -4
  4. package/agent_config/claude-code/skills/ts-procedures/anti-patterns.md +85 -17
  5. package/agent_config/claude-code/skills/ts-procedures/api-reference.md +220 -9
  6. package/agent_config/claude-code/skills/ts-procedures/patterns.md +271 -16
  7. package/agent_config/claude-code/skills/ts-procedures-review/SKILL.md +1 -1
  8. package/agent_config/claude-code/skills/ts-procedures-review/checklist.md +20 -12
  9. package/agent_config/claude-code/skills/ts-procedures-scaffold/SKILL.md +2 -1
  10. package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/client.md +53 -18
  11. package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/express-rpc.md +20 -17
  12. package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono-api.md +20 -16
  13. package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono-rpc.md +20 -17
  14. package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono-stream.md +16 -3
  15. package/agent_config/copilot/copilot-instructions.md +132 -19
  16. package/agent_config/cursor/cursorrules +132 -19
  17. package/build/client/call.d.ts +19 -9
  18. package/build/client/call.js +33 -19
  19. package/build/client/call.js.map +1 -1
  20. package/build/client/call.test.js +167 -17
  21. package/build/client/call.test.js.map +1 -1
  22. package/build/client/error-dispatch.d.ts +13 -0
  23. package/build/client/error-dispatch.js +26 -0
  24. package/build/client/error-dispatch.js.map +1 -0
  25. package/build/client/error-dispatch.test.d.ts +1 -0
  26. package/build/client/error-dispatch.test.js +56 -0
  27. package/build/client/error-dispatch.test.js.map +1 -0
  28. package/build/client/fetch-adapter.js +10 -4
  29. package/build/client/fetch-adapter.js.map +1 -1
  30. package/build/client/index.d.ts +2 -1
  31. package/build/client/index.js +22 -3
  32. package/build/client/index.js.map +1 -1
  33. package/build/client/index.test.js +104 -0
  34. package/build/client/index.test.js.map +1 -1
  35. package/build/client/resolve-options.d.ts +45 -0
  36. package/build/client/resolve-options.js +82 -0
  37. package/build/client/resolve-options.js.map +1 -0
  38. package/build/client/resolve-options.test.d.ts +1 -0
  39. package/build/client/resolve-options.test.js +158 -0
  40. package/build/client/resolve-options.test.js.map +1 -0
  41. package/build/client/stream.d.ts +19 -9
  42. package/build/client/stream.js +36 -21
  43. package/build/client/stream.js.map +1 -1
  44. package/build/client/stream.test.js +102 -46
  45. package/build/client/stream.test.js.map +1 -1
  46. package/build/client/typed-error-dispatch.test.d.ts +1 -0
  47. package/build/client/typed-error-dispatch.test.js +168 -0
  48. package/build/client/typed-error-dispatch.test.js.map +1 -0
  49. package/build/client/types.d.ts +105 -1
  50. package/build/client/types.js +1 -1
  51. package/build/codegen/e2e.test.js +150 -4
  52. package/build/codegen/e2e.test.js.map +1 -1
  53. package/build/codegen/emit-client-runtime.js +7 -0
  54. package/build/codegen/emit-client-runtime.js.map +1 -1
  55. package/build/codegen/emit-errors.d.ts +17 -6
  56. package/build/codegen/emit-errors.integration.test.d.ts +1 -0
  57. package/build/codegen/emit-errors.integration.test.js +162 -0
  58. package/build/codegen/emit-errors.integration.test.js.map +1 -0
  59. package/build/codegen/emit-errors.js +50 -39
  60. package/build/codegen/emit-errors.js.map +1 -1
  61. package/build/codegen/emit-errors.test.js +75 -78
  62. package/build/codegen/emit-errors.test.js.map +1 -1
  63. package/build/codegen/emit-index.d.ts +7 -0
  64. package/build/codegen/emit-index.js +26 -4
  65. package/build/codegen/emit-index.js.map +1 -1
  66. package/build/codegen/emit-index.test.js +55 -23
  67. package/build/codegen/emit-index.test.js.map +1 -1
  68. package/build/codegen/emit-scope.d.ts +8 -0
  69. package/build/codegen/emit-scope.js +82 -7
  70. package/build/codegen/emit-scope.js.map +1 -1
  71. package/build/codegen/pipeline.js +22 -2
  72. package/build/codegen/pipeline.js.map +1 -1
  73. package/build/implementations/http/doc-registry.d.ts +21 -0
  74. package/build/implementations/http/doc-registry.js +51 -78
  75. package/build/implementations/http/doc-registry.js.map +1 -1
  76. package/build/implementations/http/doc-registry.test.js +8 -6
  77. package/build/implementations/http/doc-registry.test.js.map +1 -1
  78. package/build/implementations/http/error-taxonomy.d.ts +240 -0
  79. package/build/implementations/http/error-taxonomy.js +230 -0
  80. package/build/implementations/http/error-taxonomy.js.map +1 -0
  81. package/build/implementations/http/error-taxonomy.test.d.ts +1 -0
  82. package/build/implementations/http/error-taxonomy.test.js +399 -0
  83. package/build/implementations/http/error-taxonomy.test.js.map +1 -0
  84. package/build/implementations/http/express-rpc/error-taxonomy.test.d.ts +1 -0
  85. package/build/implementations/http/express-rpc/error-taxonomy.test.js +83 -0
  86. package/build/implementations/http/express-rpc/error-taxonomy.test.js.map +1 -0
  87. package/build/implementations/http/express-rpc/index.d.ts +39 -8
  88. package/build/implementations/http/express-rpc/index.js +39 -8
  89. package/build/implementations/http/express-rpc/index.js.map +1 -1
  90. package/build/implementations/http/hono-api/error-taxonomy.test.d.ts +1 -0
  91. package/build/implementations/http/hono-api/error-taxonomy.test.js +137 -0
  92. package/build/implementations/http/hono-api/error-taxonomy.test.js.map +1 -0
  93. package/build/implementations/http/hono-api/index.d.ts +38 -1
  94. package/build/implementations/http/hono-api/index.js +32 -0
  95. package/build/implementations/http/hono-api/index.js.map +1 -1
  96. package/build/implementations/http/hono-rpc/error-taxonomy.test.d.ts +1 -0
  97. package/build/implementations/http/hono-rpc/error-taxonomy.test.js +64 -0
  98. package/build/implementations/http/hono-rpc/error-taxonomy.test.js.map +1 -0
  99. package/build/implementations/http/hono-rpc/index.d.ts +34 -7
  100. package/build/implementations/http/hono-rpc/index.js +31 -4
  101. package/build/implementations/http/hono-rpc/index.js.map +1 -1
  102. package/build/implementations/http/hono-stream/error-taxonomy.test.d.ts +1 -0
  103. package/build/implementations/http/hono-stream/error-taxonomy.test.js +87 -0
  104. package/build/implementations/http/hono-stream/error-taxonomy.test.js.map +1 -0
  105. package/build/implementations/http/hono-stream/index.d.ts +40 -3
  106. package/build/implementations/http/hono-stream/index.js +37 -10
  107. package/build/implementations/http/hono-stream/index.js.map +1 -1
  108. package/build/implementations/http/hono-stream/index.test.js +45 -18
  109. package/build/implementations/http/hono-stream/index.test.js.map +1 -1
  110. package/build/implementations/http/on-request-error.test.d.ts +1 -0
  111. package/build/implementations/http/on-request-error.test.js +173 -0
  112. package/build/implementations/http/on-request-error.test.js.map +1 -0
  113. package/build/implementations/http/route-errors.test.d.ts +1 -0
  114. package/build/implementations/http/route-errors.test.js +140 -0
  115. package/build/implementations/http/route-errors.test.js.map +1 -0
  116. package/build/implementations/types.d.ts +30 -2
  117. package/docs/client-and-codegen.md +228 -14
  118. package/docs/core.md +14 -5
  119. package/docs/http-integrations.md +135 -4
  120. package/docs/streaming.md +3 -1
  121. package/package.json +7 -2
  122. package/src/client/call.test.ts +202 -29
  123. package/src/client/call.ts +50 -28
  124. package/src/client/error-dispatch.test.ts +72 -0
  125. package/src/client/error-dispatch.ts +27 -0
  126. package/src/client/fetch-adapter.ts +11 -5
  127. package/src/client/index.test.ts +117 -0
  128. package/src/client/index.ts +34 -8
  129. package/src/client/resolve-options.test.ts +205 -0
  130. package/src/client/resolve-options.ts +113 -0
  131. package/src/client/stream.test.ts +132 -107
  132. package/src/client/stream.ts +53 -27
  133. package/src/client/typed-error-dispatch.test.ts +211 -0
  134. package/src/client/types.ts +116 -2
  135. package/src/codegen/e2e.test.ts +160 -4
  136. package/src/codegen/emit-client-runtime.ts +7 -0
  137. package/src/codegen/emit-errors.integration.test.ts +183 -0
  138. package/src/codegen/emit-errors.test.ts +91 -87
  139. package/src/codegen/emit-errors.ts +123 -41
  140. package/src/codegen/emit-index.test.ts +68 -24
  141. package/src/codegen/emit-index.ts +66 -4
  142. package/src/codegen/emit-scope.ts +124 -7
  143. package/src/codegen/pipeline.ts +25 -2
  144. package/src/implementations/http/README.md +28 -5
  145. package/src/implementations/http/doc-registry.test.ts +10 -6
  146. package/src/implementations/http/doc-registry.ts +63 -80
  147. package/src/implementations/http/error-taxonomy.test.ts +438 -0
  148. package/src/implementations/http/error-taxonomy.ts +337 -0
  149. package/src/implementations/http/express-rpc/README.md +21 -22
  150. package/src/implementations/http/express-rpc/error-taxonomy.test.ts +103 -0
  151. package/src/implementations/http/express-rpc/index.ts +75 -14
  152. package/src/implementations/http/hono-api/README.md +284 -0
  153. package/src/implementations/http/hono-api/error-taxonomy.test.ts +179 -0
  154. package/src/implementations/http/hono-api/index.ts +76 -1
  155. package/src/implementations/http/hono-rpc/README.md +18 -19
  156. package/src/implementations/http/hono-rpc/error-taxonomy.test.ts +82 -0
  157. package/src/implementations/http/hono-rpc/index.ts +65 -9
  158. package/src/implementations/http/hono-stream/README.md +44 -25
  159. package/src/implementations/http/hono-stream/error-taxonomy.test.ts +98 -0
  160. package/src/implementations/http/hono-stream/index.test.ts +54 -18
  161. package/src/implementations/http/hono-stream/index.ts +83 -13
  162. package/src/implementations/http/on-request-error.test.ts +201 -0
  163. package/src/implementations/http/route-errors.test.ts +177 -0
  164. package/src/implementations/types.ts +30 -2
package/README.md CHANGED
@@ -50,6 +50,8 @@ const user2 = await procedure({}, { userId: '456' })
50
50
 
51
51
  - **[Client Code Generation](docs/client-and-codegen.md)** — Generate type-safe client SDKs from your server's `DocRegistry`. CLI and programmatic API, adapters, hooks, streaming support, and self-contained mode.
52
52
 
53
+ - **[Typed Error Handling](docs/http-integrations.md#error-handling)** — Declarative `defineErrorTaxonomy` maps thrown error classes to HTTP responses across every builder; generated clients throw typed class instances you can catch with `instanceof`.
54
+
53
55
  - **[AI Agent Setup](docs/ai-agent-setup.md)** — Built-in configuration for Claude Code, Cursor, and GitHub Copilot. Auto-updates on `npm install`.
54
56
 
55
57
  Full documentation is available on [GitHub](https://github.com/thermsio/ts-procedures).
@@ -37,7 +37,10 @@ When asked to plan an API or procedure set:
37
37
  - AJV is configured with `allErrors: true`, `coerceTypes: true`, `removeAdditional: true`.
38
38
  - `schema.params` and `schema.input` are mutually exclusive — defining both throws `ProcedureRegistrationError`.
39
39
  - Path param names in route template (`:id`) must match `schema.input.pathParams` property names.
40
- - Use `DocRegistry` to compose route docs from multiple builders — never manually wire `/docs` endpoints.
40
+ - Use `DocRegistry` to compose route docs from multiple builders — never manually wire `/docs` endpoints. Use `DocRegistry.fromTaxonomy(appErrors)` to seed envelope errors from your taxonomy + framework defaults in one call.
41
+ - Two first-class peer error-handling modes: **declarative taxonomy** (`defineErrorTaxonomy` + `errors` config) OR **imperative callback** (`onError`). Neither is deprecated. Pick the taxonomy for structured apps with typed client dispatch; pick `onError` for simple apps or full response control. Mixing both is allowed — the taxonomy handles what it covers, `onError` handles the tail. The anti-pattern is `instanceof` ladders inside `onError` (see anti-pattern #20 in the skill reference) — that's exactly what the taxonomy expresses declaratively.
42
+ - Per-route `errors: ['UseCaseError', ...]` narrows typed errors on the generated client. Declare via `APIConfig<keyof typeof appErrors & string>` / `RPCConfig<keyof typeof appErrors & string>` for compile-time typo protection.
43
+ - Generated `_errors.ts` emits real runtime classes extending `${Service}ProcedureError` — consumers catch with `instanceof ${Service}Errors.${Name}` and access `err.body`, `err.status`, `err.procedureName`, `err.scope`. Use the generated `create${Service}Client(config)` factory to wire the error registry automatically.
41
44
 
42
45
  ## Context Design Patterns
43
46
 
@@ -120,11 +123,15 @@ type AuthContext = { userId: string; requestId: string; db: Database }
120
123
  - POST /api/users (HonoAPIAppBuilder, 201)
121
124
  - DELETE /api/users/:id (HonoAPIAppBuilder, 204)
122
125
 
123
- ### Error Handling
124
- - Input validation: automatic (schema.params)
125
- - Auth failures: ctx.error('Unauthorized', { code: 401 })
126
- - Not found: ctx.error('User not found', { code: 404 })
127
- - Stream errors: onMidStreamError -> yield error event, close stream
126
+ ### Error Handling (pick a mode, optionally combine)
127
+ - **Declarative mode (recommended for structured apps)**: `defineErrorTaxonomy({ AuthError: {class, 401}, NotFoundError: {class, 404}, ... })` wired via `errors` config. Drives typed client dispatch + DocEnvelope.
128
+ - **Imperative mode (simple apps or full response control)**: `onError: (procedure, c|req/res, err) => Response|void` — first-class peer.
129
+ - `unknownError` fallback serializer for errors the taxonomy doesn't cover (pairs with declarative mode).
130
+ - `onRequestError` cross-cutting observer for logging/tracing/metrics. Fires for every error, before dispatch.
131
+ - Input validation is automatic (default taxonomy: `ProcedureValidationError` → 400).
132
+ - Business errors: throw typed class instances — registered taxonomy classes auto-serialize, or handle in `onError`.
133
+ - Per-route: declare `errors: ['AuthError', ...]` on each route config so the generated client narrows `catch` types.
134
+ - Stream pre-errors: both peer modes apply. Mid-stream errors: `onMidStreamError` → yield error event, close stream.
128
135
  ```
129
136
 
130
137
  ## Next Steps
@@ -76,9 +76,29 @@ AJV config: `allErrors: true`, `coerceTypes: true`, `removeAdditional: true`
76
76
  | `ProcedureValidationError` | Schema params validation failure | `procedureName`, `errors[]` (AJV errors) |
77
77
  | `ProcedureYieldValidationError` | Stream yield validation failure | `procedureName`, `errors[]` (AJV errors) |
78
78
  | `ProcedureRegistrationError` | Invalid schema at registration time | `procedureName`, `message` |
79
+ | `${Service}ProcedureError` (generated, client) | Base class for all generated client error classes | `status`, `procedureName`, `scope`, `body` |
79
80
 
80
81
  All errors include `definedAt` (file:line:column) and enhanced stack traces pointing to the procedure definition location.
81
82
 
83
+ ## Error Taxonomy (HTTP builders)
84
+
85
+ Declare error classes once with `defineErrorTaxonomy` (from `ts-procedures/http-errors`) and pass to any HTTP builder via the `errors` option. Handlers `throw` their classes; the builder serializes to the configured status + body. This is the declarative peer of the imperative `onError` callback — both modes are first-class.
86
+
87
+ ```typescript
88
+ import { defineErrorTaxonomy } from 'ts-procedures/http-errors'
89
+
90
+ const appErrors = defineErrorTaxonomy({
91
+ AuthError: { class: AuthError, statusCode: 401 },
92
+ UseCaseError: { class: UseCaseError, statusCode: 422, toResponse: (e) => ({ message: e.externalMsg }) },
93
+ })
94
+
95
+ new HonoAPIAppBuilder({ errors: appErrors, unknownError: { toResponse: () => ({ error: '...' }) } })
96
+ ```
97
+
98
+ Per-route narrowing: `APIConfig<keyof typeof appErrors & string>` / `RPCConfig<keyof typeof appErrors & string>` with a `errors: [...]` array on the config. Generated `_errors.ts` emits runtime classes — clients catch with `instanceof ${Service}Errors.${Name}` when using `create${Service}Client`.
99
+
100
+ Full contract: `docs/http-integrations.md § Error Handling` (canonical) and `api-reference.md § Error Taxonomy API`.
101
+
82
102
  ## HTTP Implementations
83
103
 
84
104
  | Builder | Import | Transport |
@@ -112,10 +132,11 @@ const app = new ExpressRPCAppBuilder({ pathPrefix: '/api' })
112
132
  | `onRequestStart` | Before context resolution |
113
133
  | `onRequestEnd` | After response sent |
114
134
  | `onSuccess` | After successful handler execution |
115
- | `onError` | On handler error (return custom response) |
135
+ | `onError` | Imperative error callback first-class peer of the declarative `errors` taxonomy |
116
136
  | `onStreamStart` | Before first yield (HonoStreamAppBuilder) |
117
137
  | `onStreamEnd` | After stream closes (HonoStreamAppBuilder) |
118
- | `onPreStreamError` | Validation/context error before streaming starts |
138
+ | `onError` (HonoStream) | Imperative pre-stream error callback peer of `errors` taxonomy |
139
+ | `onRequestError` | Cross-cutting observer — fires for every caught error before dispatch |
119
140
  | `onMidStreamError` | Error during streaming (return data to yield as final event) |
120
141
 
121
142
  ## Decision Framework
@@ -150,8 +171,9 @@ The npm package ships user-facing documentation with narrative explanations and
150
171
  |------|--------|
151
172
  | `docs/core.md` | Procedures factory, Create, CreateStream, schema.input, error handling |
152
173
  | `docs/streaming.md` | Streaming procedures, AbortSignal, SSE patterns |
153
- | `docs/http-integrations.md` | Express RPC, Hono RPC/Stream/API builders, DocRegistry |
154
- | `docs/client-and-codegen.md` | Client code generation, createClient, CLI options |
174
+ | `docs/http-integrations.md` | Express RPC, Hono RPC/Stream/API builders, **error taxonomy (canonical)**, DocRegistry + `fromTaxonomy` |
175
+ | `docs/client-and-codegen.md` | Client code generation, `createApiClient`/`createClient`, typed error dispatch, per-route `Errors` unions, per-call options, client-level defaults, typed RequestMeta augmentation, CLI options |
176
+ | `CHANGELOG.md` | Release notes — see `[6.0.0]` for the current peer error-handling model (taxonomy + `onError` + `onRequestError`), per-route errors, and client runtime error classes. |
155
177
 
156
178
  ## Workflow
157
179
 
@@ -404,19 +404,21 @@ new HonoStreamAppBuilder({
404
404
  })
405
405
  ```
406
406
 
407
- **Fix:** Use `onPreStreamError` for validation errors and `onMidStreamError` for runtime errors.
407
+ **Fix:** Use either peer mode for pre-stream errors — the declarative taxonomy (`errors` + `unknownError`) or the imperative `onError` callback (renamed from `onPreStreamError` in v6). Mid-stream errors thrown after the first yield always go through `onMidStreamError` since HTTP status is already committed.
408
408
 
409
409
  ```typescript
410
410
  // GOOD
411
+ import { defineErrorTaxonomy } from 'ts-procedures/http-errors'
412
+
411
413
  new HonoStreamAppBuilder({
412
- onPreStreamError: (proc, c, error) => {
413
- // Validation/context error — return normal HTTP response
414
- return c.json({ error: error.message }, 400)
415
- },
416
- onMidStreamError: (proc, c, error) => {
417
- // Runtime error during streamingyield error event, then close
418
- return { data: { error: error.message }, closeStream: true }
419
- },
414
+ // Taxonomy handles all pre-stream errors (validation, auth, context)
415
+ errors: defineErrorTaxonomy({
416
+ AuthError: { class: AuthError, statusCode: 401 },
417
+ }),
418
+ unknownError: { toResponse: (err) => ({ error: (err as Error).message }) },
419
+ // Mid-stream still goes through onMidStreamError the HTTP status is
420
+ // already committed, so errors become yields, not responses.
421
+ onMidStreamError: (proc, c, error) => ({ data: { error: error.message }, closeStream: true }),
420
422
  })
421
423
  ```
422
424
 
@@ -496,21 +498,37 @@ builder.register(factory, async (req) => {
496
498
  })
497
499
  ```
498
500
 
499
- **Fix:** Use the builder's `onError` callback to handle context resolution failures gracefully.
501
+ **Fix:** Throw a typed error class from the context factory and register it in the builder's `errors` taxonomy the builder auto-serializes.
500
502
 
501
503
  ```typescript
502
504
  // GOOD
503
- new ExpressRPCAppBuilder({
504
- onError: (procedure, req, res, error) => {
505
- if (error.message.includes('Unauthorized')) {
506
- res.status(401).json({ error: 'Unauthorized' })
507
- } else {
508
- res.status(500).json({ error: 'Internal server error' })
509
- }
505
+ import { defineErrorTaxonomy } from 'ts-procedures/http-errors'
506
+
507
+ class AuthError extends Error {
508
+ constructor(message: string) { super(message); this.name = 'AuthError' }
509
+ }
510
+
511
+ const appErrors = defineErrorTaxonomy({
512
+ AuthError: {
513
+ class: AuthError,
514
+ statusCode: 401,
515
+ toResponse: () => ({ message: 'Unauthorized' }),
510
516
  },
511
517
  })
518
+
519
+ const app = new ExpressRPCAppBuilder({
520
+ errors: appErrors,
521
+ unknownError: { toResponse: () => ({ error: 'Internal server error' }) },
522
+ })
523
+ .register(factory, async (req) => {
524
+ const user = await authenticate(req.headers.authorization)
525
+ if (!user) throw new AuthError('invalid token')
526
+ return { userId: user.id }
527
+ })
512
528
  ```
513
529
 
530
+ The imperative `onError: (procedure, req, res, error) => void` callback is a first-class peer — use it for apps that prefer a single response handler or when you want to handle the tail of errors the taxonomy doesn't cover.
531
+
514
532
  ---
515
533
 
516
534
  ## 18. Using Both schema.params and schema.input
@@ -592,6 +610,55 @@ Create('GetUser', {
592
610
 
593
611
  ---
594
612
 
613
+ ## 20. Hand-writing `onError` instanceof ladders
614
+
615
+ **Problem:** Writing manual `instanceof` ladders inside `onError` to map each error class to a status + body. Every builder gets its own copy; generated clients see opaque `ClientRequestError` objects instead of typed instances; response shapes drift between routes.
616
+
617
+ ```typescript
618
+ // BAD — duplicated across builders, invisible to generated clients
619
+ new HonoAPIAppBuilder({
620
+ onError: (procedure, c, error) => {
621
+ if (error instanceof ValidationError) return c.json({ error: error.message }, 400)
622
+ if (error instanceof AuthError) return c.json({ error: 'Unauthorized' }, 401)
623
+ if (error instanceof UseCaseError) return c.json({ message: error.externalMsg }, 422)
624
+ return c.json({ error: 'Internal server error' }, 500)
625
+ },
626
+ })
627
+ ```
628
+
629
+ **Fix:** Declare the taxonomy once with `defineErrorTaxonomy`, pass it via the builder's `errors` config, and handlers `throw` their own classes. The taxonomy also feeds the DocEnvelope so generated clients throw typed class instances.
630
+
631
+ ```typescript
632
+ // GOOD
633
+ import { defineErrorTaxonomy } from 'ts-procedures/http-errors'
634
+
635
+ const appErrors = defineErrorTaxonomy({
636
+ ValidationError: { class: ValidationError, statusCode: 400 },
637
+ AuthError: { class: AuthError, statusCode: 401 },
638
+ UseCaseError: {
639
+ class: UseCaseError,
640
+ statusCode: 422,
641
+ toResponse: (err) => ({ message: err.externalMsg }), // `name` auto-injected
642
+ },
643
+ })
644
+
645
+ new HonoAPIAppBuilder({
646
+ errors: appErrors,
647
+ unknownError: {
648
+ toResponse: () => ({ error: 'Internal server error' }),
649
+ onCatch: (err, { procedure }) => logger.error({ procedure: procedure.name, err }),
650
+ },
651
+ })
652
+ ```
653
+
654
+ The same `errors` / `unknownError` shape plugs into every builder (`HonoAPIAppBuilder`, `HonoRPCAppBuilder`, `ExpressRPCAppBuilder`, `HonoStreamAppBuilder` pre-stream only). `ctx.error()` still works and is caught by the default taxonomy at 500.
655
+
656
+ **The anti-pattern is the ladder, not `onError`.** `onError` is a first-class peer of the taxonomy — it's perfectly fine for simple apps or when you want full response control. The problem is writing `instanceof` ladders *inside* it to route by error class, because that's exactly what the taxonomy is designed to express declaratively.
657
+
658
+ **Why:** The taxonomy is declarative (easy to audit), shared across builders, and drives client codegen — the generated `_errors.ts` emits real runtime classes so consumers can `catch (err) { if (err instanceof ApiErrors.UseCaseError) ... }` with full typing.
659
+
660
+ ---
661
+
595
662
  ## Summary Table
596
663
 
597
664
  | # | Anti-Pattern | Risk | Severity |
@@ -615,3 +682,4 @@ Create('GetUser', {
615
682
  | 17 | Unhandled async context factory | Request crashes | WARNING |
616
683
  | 18 | Both schema.params and schema.input | ProcedureRegistrationError at startup | CRITICAL |
617
684
  | 19 | Mismatched path param names | Build-time error or confusing validation failures | CRITICAL |
685
+ | 20 | Hand-writing onError instanceof ladders | Drifting response shapes, untyped client errors | WARNING |
@@ -256,6 +256,125 @@ class ProcedureRegistrationError extends ProcedureError {
256
256
 
257
257
  ---
258
258
 
259
+ ## Error Taxonomy API
260
+
261
+ Exported from `ts-procedures/http-errors` (and re-exported from every HTTP builder subpath: `ts-procedures/hono-api`, `ts-procedures/hono-rpc`, `ts-procedures/express-rpc`, `ts-procedures/hono-stream`).
262
+
263
+ ### defineErrorTaxonomy(entries)
264
+
265
+ ```typescript
266
+ function defineErrorTaxonomy<T extends ErrorTaxonomy>(entries: T): T
267
+ ```
268
+
269
+ Identity helper that:
270
+ 1. Validates each entry has exactly one discriminator (`class` xor `match`) — throws otherwise.
271
+ 2. Topologically sorts `class:` entries so subclasses always precede base classes regardless of declared order. Predicate (`match:`) entries keep declared order.
272
+
273
+ ### ErrorTaxonomyEntry
274
+
275
+ ```typescript
276
+ type ErrorTaxonomyEntry<TError = any, TBody = unknown> = {
277
+ class?: new (...args: any[]) => TError // instanceof discriminator — XOR with `match`
278
+ match?: (err: unknown) => err is TError // predicate discriminator — for 3rd-party errors
279
+ statusCode: number
280
+ description?: string // consumed by DocRegistry
281
+ schema?: Record<string, unknown> // response-body JSON Schema; consumed by DocRegistry
282
+ toResponse?: (err: TError, meta: { key: string }) => TBody
283
+ onCatch?: (
284
+ err: TError,
285
+ ctx: { procedure: TAnyProcedureRegistration; key: string; raw: unknown }
286
+ ) => void | Promise<void>
287
+ }
288
+
289
+ type ErrorTaxonomy = Record<string, ErrorTaxonomyEntry>
290
+ ```
291
+
292
+ When `toResponse` is omitted, the body defaults to `{ name: key, message: err.message }`. When `toResponse` returns an object without a `name` field, the resolver auto-injects `{ name: key }` — wire-protocol consistency is guaranteed.
293
+
294
+ ### defaultErrorTaxonomy
295
+
296
+ Pre-built taxonomy covering `ProcedureValidationError` (400), `ProcedureYieldValidationError` (500), and direct `ProcedureError` (500, unwrapped throws only — wrapped ones fall through so user taxonomy / `unknownError` sees the real error). Applied as a fallback after the user taxonomy unless `includeDefaults: false`.
297
+
298
+ ### taxonomyToErrorDocs(taxonomy)
299
+
300
+ ```typescript
301
+ function taxonomyToErrorDocs(taxonomy: ErrorTaxonomy): ErrorDoc[]
302
+ ```
303
+
304
+ Converts a taxonomy to the `ErrorDoc[]` format consumed by `DocRegistry.errors` — single source of truth so the documented shape cannot drift from runtime behavior.
305
+
306
+ ### resolveErrorResponse(params)
307
+
308
+ ```typescript
309
+ function resolveErrorResponse(params: {
310
+ err: unknown
311
+ userTaxonomy?: ErrorTaxonomy
312
+ includeDefaults?: boolean // default true
313
+ unknownError?: UnknownErrorConfig
314
+ procedure: TAnyProcedureRegistration
315
+ raw: unknown
316
+ }): { statusCode: number; body: unknown; runOnCatch: () => Promise<void> } | null
317
+ ```
318
+
319
+ Match order: user taxonomy → default taxonomy (when `includeDefaults`) → `unknownError`. Returns `null` when nothing matches — callers fall through to their builder's imperative `onError` callback and then the hard default. Unwraps `ProcedureError.cause` so user taxonomy matches against the real thrown error, not the wrapper. Side effects (`onCatch`) are deferred into `runOnCatch` so the caller decides when to execute them.
320
+
321
+ ### UnknownErrorConfig
322
+
323
+ ```typescript
324
+ type UnknownErrorConfig = {
325
+ statusCode?: number // default 500
326
+ toResponse: (err: unknown) => unknown
327
+ onCatch?: (
328
+ err: unknown,
329
+ ctx: { procedure: TAnyProcedureRegistration; raw: unknown }
330
+ ) => void | Promise<void>
331
+ }
332
+ ```
333
+
334
+ ---
335
+
336
+ ## Client Error Registry API
337
+
338
+ Exported from `ts-procedures/client`. Generated clients hook their `${Service}ErrorRegistry` into `createClient` so non-2xx responses arrive as typed class instances.
339
+
340
+ ### ErrorRegistry / ErrorFactory / ErrorResponseMeta
341
+
342
+ ```typescript
343
+ interface ErrorResponseMeta { status: number; procedureName: string; scope: string }
344
+ interface ErrorFactory { fromResponse(body: unknown, meta: ErrorResponseMeta): Error }
345
+ type ErrorRegistry = Record<string, ErrorFactory>
346
+ ```
347
+
348
+ ### dispatchTypedError(registry, body, meta)
349
+
350
+ ```typescript
351
+ function dispatchTypedError(
352
+ registry: ErrorRegistry | undefined,
353
+ body: unknown,
354
+ meta: ErrorResponseMeta
355
+ ): Error | null
356
+ ```
357
+
358
+ Returns a typed error instance when:
359
+ - `registry` is defined, AND
360
+ - `body` is an object with a string `name`, AND
361
+ - `registry[body.name].fromResponse(body, meta)` returns an `Error` subclass.
362
+
363
+ Otherwise returns `null`; callers fall back to `ClientRequestError`.
364
+
365
+ ### CreateClientConfig.errorRegistry
366
+
367
+ ```typescript
368
+ interface CreateClientConfig<TScopes> {
369
+ // ...existing fields
370
+ errorRegistry?: ErrorRegistry
371
+ }
372
+ ```
373
+
374
+ Threaded into both `executeCall` and `executeStream`. The generated `create${Service}Client(config)` factory wires this automatically.
375
+
376
+ ---
377
+
259
378
  ## Schema Utilities
260
379
 
261
380
  ### extractJsonSchema(libSchema)
@@ -308,7 +427,11 @@ class ExpressRPCAppBuilder {
308
427
  onRequestStart?: (req: express.Request) => void
309
428
  onRequestEnd?: (req: express.Request, res: express.Response) => void
310
429
  onSuccess?: (procedure: TProcedureRegistration, req: express.Request, res: express.Response) => void
430
+ // Error handling — two peer modes plus a cross-cutting observer.
431
+ errors?: ErrorTaxonomy
432
+ unknownError?: UnknownErrorConfig
311
433
  onError?: (procedure: TProcedureRegistration, req: express.Request, res: express.Response, error: Error) => void
434
+ onRequestError?: (ctx: OnRequestErrorContext) => void | Promise<void>
312
435
  })
313
436
 
314
437
  register<TFactory>(
@@ -326,6 +449,8 @@ class ExpressRPCAppBuilder {
326
449
  }
327
450
  ```
328
451
 
452
+ `OnRequestErrorContext` for Express: `{ err: unknown; procedure: TProcedureRegistration; raw: { req, res } }`.
453
+
329
454
  ### Route Path
330
455
 
331
456
  `POST {pathPrefix}/{scope}/{kebab-name}/{version}`
@@ -357,7 +482,11 @@ class HonoRPCAppBuilder {
357
482
  onRequestStart?: (c: Context) => void
358
483
  onRequestEnd?: (c: Context) => void
359
484
  onSuccess?: (procedure: TProcedureRegistration, c: Context) => void
485
+ // Error handling — two peer modes plus a cross-cutting observer.
486
+ errors?: ErrorTaxonomy
487
+ unknownError?: UnknownErrorConfig
360
488
  onError?: (procedure: TProcedureRegistration, c: Context, error: Error) => Response | Promise<Response>
489
+ onRequestError?: (ctx: OnRequestErrorContext) => void | Promise<void>
361
490
  })
362
491
 
363
492
  register<TFactory>(
@@ -372,6 +501,8 @@ class HonoRPCAppBuilder {
372
501
  }
373
502
  ```
374
503
 
504
+ `OnRequestErrorContext` for Hono RPC: `{ err: unknown; procedure: TProcedureRegistration; raw: Context }`.
505
+
375
506
  ### Signal Injection
376
507
 
377
508
  Uses `c.req.raw.signal` — the native Request signal from the Hono context.
@@ -392,7 +523,10 @@ class HonoStreamAppBuilder<TErrorData = unknown> {
392
523
  onRequestEnd?: (c: Context) => void
393
524
  onStreamStart?: (procedure: TStreamProcedureRegistration, c: Context, streamMode: StreamMode) => void
394
525
  onStreamEnd?: (procedure: TStreamProcedureRegistration, c: Context, streamMode: StreamMode) => void
395
- onPreStreamError?: (procedure: TStreamProcedureRegistration, c: Context, error: Error) => Response | Promise<Response>
526
+ errors?: ErrorTaxonomy
527
+ unknownError?: UnknownErrorConfig
528
+ onError?: (procedure: TStreamProcedureRegistration, c: Context, error: Error) => Response | Promise<Response> // renamed from onPreStreamError in v6
529
+ onRequestError?: (ctx: { err: unknown; procedure: TStreamProcedureRegistration; raw: Context }) => void | Promise<void>
396
530
  onMidStreamError?: (procedure: TStreamProcedureRegistration, c: Context, error: Error) => MidStreamErrorResult<TErrorData> | undefined
397
531
  })
398
532
 
@@ -451,8 +585,8 @@ type MidStreamErrorResult<TErrorData = unknown> = {
451
585
 
452
586
  ### Error Handling
453
587
 
454
- - **Pre-stream errors** (validation, context): Returns JSON error response (no streaming started). Custom handling via `onPreStreamError`.
455
- - **Mid-stream errors** (generator throws): Yields error as final event, closes stream. Custom handling via `onMidStreamError`.
588
+ - **Pre-stream errors** (validation, context): Returns a JSON error response (no streaming started). Handle via either peer mode — `errors` taxonomy or `onError` callback. Observe via `onRequestError`.
589
+ - **Mid-stream errors** (generator throws): Yields error as final event, closes stream. Handle via `onMidStreamError` (the only mid-stream path — the HTTP status is already committed).
456
590
 
457
591
  ---
458
592
 
@@ -469,7 +603,11 @@ class HonoAPIAppBuilder {
469
603
  onRequestStart?: (c: Context) => void
470
604
  onRequestEnd?: (c: Context) => void
471
605
  onSuccess?: (procedure: TProcedureRegistration, c: Context) => void
606
+ // Error handling — two peer modes plus a cross-cutting observer.
607
+ errors?: ErrorTaxonomy
608
+ unknownError?: UnknownErrorConfig
472
609
  onError?: (procedure: TProcedureRegistration, c: Context, error: Error) => Response | Promise<Response>
610
+ onRequestError?: (ctx: OnRequestErrorContext) => void | Promise<void>
473
611
  })
474
612
 
475
613
  register<TFactory>(
@@ -484,6 +622,8 @@ class HonoAPIAppBuilder {
484
622
  }
485
623
  ```
486
624
 
625
+ `OnRequestErrorContext` for Hono API: `{ err: unknown; procedure: TProcedureRegistration; raw: Context }`.
626
+
487
627
  ### Route Path
488
628
 
489
629
  Developer-defined via `APIConfig.path` — no auto-generation. Supports Hono path params (`:id`).
@@ -553,9 +693,12 @@ Uses `c.req.raw.signal` — native Request signal from Hono context.
553
693
  ### RPCConfig
554
694
 
555
695
  ```typescript
556
- interface RPCConfig {
696
+ // Generic over valid taxonomy keys. Default TErrorKey = string (permissive).
697
+ // Narrow via `RPCConfig<keyof typeof appErrors & string>` for compile-time typo protection.
698
+ interface RPCConfig<TErrorKey extends string = string> {
557
699
  scope: string | string[]
558
700
  version: number
701
+ errors?: TErrorKey[] // Taxonomy keys this procedure may emit (informational; populates DocEnvelope).
559
702
  }
560
703
  ```
561
704
 
@@ -570,6 +713,7 @@ interface RPCHttpRouteDoc extends RPCConfig {
570
713
  body?: Record<string, unknown>
571
714
  response?: Record<string, unknown>
572
715
  }
716
+ errors?: string[] // Per-route subset, populated from config.errors.
573
717
  }
574
718
  ```
575
719
 
@@ -586,16 +730,20 @@ interface StreamHttpRouteDoc extends RPCConfig {
586
730
  yieldType?: Record<string, unknown>
587
731
  returnType?: Record<string, unknown>
588
732
  }
733
+ errors?: string[]
589
734
  }
590
735
  ```
591
736
 
592
737
  ### APIConfig
593
738
 
594
739
  ```typescript
595
- interface APIConfig {
740
+ // Generic over valid taxonomy keys — same pattern as RPCConfig.
741
+ interface APIConfig<TErrorKey extends string = string> {
596
742
  path: string
597
743
  method: 'get' | 'post' | 'put' | 'delete' | 'patch' | 'head'
598
744
  successStatus?: number
745
+ scope?: string
746
+ errors?: TErrorKey[]
599
747
  }
600
748
  ```
601
749
 
@@ -612,6 +760,7 @@ interface APIHttpRouteDoc extends APIConfig {
612
760
  headers?: Record<string, unknown>
613
761
  response?: Record<string, unknown>
614
762
  }
763
+ errors?: string[]
615
764
  }
616
765
  ```
617
766
 
@@ -660,7 +809,16 @@ class DocRegistry {
660
809
  transform?: (envelope: DocEnvelope) => T
661
810
  }): T
662
811
 
812
+ // Framework error defaults derived from defaultErrorTaxonomy — single source
813
+ // of truth so runtime and documented shapes cannot drift.
663
814
  static defaultErrors(): ErrorDoc[]
815
+
816
+ // Convenience: seed envelope errors from a taxonomy + framework defaults
817
+ // in one call (deduped — user entries win when keys overlap).
818
+ static fromTaxonomy(
819
+ taxonomy: ErrorTaxonomy,
820
+ config?: Omit<DocRegistryConfig, 'errors'> & { includeDefaults?: boolean }
821
+ ): DocRegistry
664
822
  }
665
823
  ```
666
824
 
@@ -732,6 +890,7 @@ function createClient<TScopes>(config: {
732
890
  basePath: string
733
891
  scopes: (client: ClientInstance) => TScopes
734
892
  hooks?: ClientHooks
893
+ defaults?: ProcedureCallDefaults
735
894
  }): TScopes
736
895
  ```
737
896
 
@@ -740,7 +899,8 @@ function createClient<TScopes>(config: {
740
899
  - `config.adapter` — Transport adapter implementing `ClientAdapter`. Use `createFetchAdapter()` for fetch-based transport.
741
900
  - `config.basePath` — Base URL prepended to all request paths (e.g., `'http://localhost:3000'`).
742
901
  - `config.scopes` — Factory function that receives a raw `ClientInstance` and returns the typed scope object. The generated `create${ServiceName}Bindings` export (defaults to `createApiBindings`; pass `--service-name <Name>` to rename).
743
- - `config.hooks` — Optional global hooks applied to every call. Can be overridden per call.
902
+ - `config.hooks` — Optional global hooks applied to every call. Per-call hooks (passed via the options bag) run *after* global hooks — they stack, not replace.
903
+ - `config.defaults` — Optional default request options (timeout, signal, headers, basePath) applied to every call. Overridden by per-call options.
744
904
 
745
905
  ### Return Value
746
906
 
@@ -750,11 +910,51 @@ Returns the result of `config.scopes(clientInstance)` — a typed object where e
750
910
 
751
911
  ```typescript
752
912
  interface ClientHooks {
753
- onBeforeRequest?: (ctx: { request: ClientRequest }) => { request: ClientRequest } | void
754
- onAfterResponse?: (ctx: { request: ClientRequest; response: ClientResponse }) => void
913
+ onBeforeRequest?: (ctx: BeforeRequestContext) => BeforeRequestContext | Promise<BeforeRequestContext>
914
+ onAfterResponse?: (ctx: AfterResponseContext) => void | Promise<void>
915
+ onError?: (ctx: ErrorContext) => void | Promise<void>
755
916
  }
756
917
  ```
757
918
 
919
+ ### ProcedureCallDefaults / ProcedureCallOptions
920
+
921
+ `ProcedureCallDefaults` is the shape used at `config.defaults` and is a strict subset of `ProcedureCallOptions` (per-call options). `ProcedureCallOptions` additionally includes the `ClientHooks` fields so a single options bag covers both request config and hooks.
922
+
923
+ ```typescript
924
+ interface ProcedureCallDefaults {
925
+ signal?: AbortSignal // cancel signal (combined with per-call via AbortSignal.any)
926
+ timeout?: number // ms — per-call timeout:0 disables an inherited default
927
+ headers?: Record<string, string> // merged (per-call keys win)
928
+ basePath?: string // per-call > default > config.basePath
929
+ meta?: RequestMeta // typed per-request metadata (see RequestMeta below)
930
+ }
931
+
932
+ interface ProcedureCallOptions extends ProcedureCallDefaults, ClientHooks {}
933
+ ```
934
+
935
+ ### RequestMeta (typed per-request metadata)
936
+
937
+ `RequestMeta` is an empty interface designed for TypeScript declaration merging. Augment it in your project to type the `meta` field end-to-end (per-call options, hook contexts, and the adapter).
938
+
939
+ ```typescript
940
+ // Self-contained (code-generated) client
941
+ declare module './generated/_types' {
942
+ interface RequestMeta {
943
+ traceId: string
944
+ priority?: 'high' | 'low'
945
+ }
946
+ }
947
+
948
+ // Or when using ts-procedures/client directly
949
+ declare module 'ts-procedures/client' {
950
+ interface RequestMeta {
951
+ traceId: string
952
+ }
953
+ }
954
+ ```
955
+
956
+ After augmentation, `request.meta` is typed in adapters, hooks, defaults, and per-call options. If `RequestMeta` declares required fields, supply them via `defaults.meta` or per-call `options.meta` — the merged shape must satisfy them at runtime.
957
+
758
958
  ### Example
759
959
 
760
960
  ```typescript
@@ -765,6 +965,7 @@ const client = createClient({
765
965
  adapter: createFetchAdapter(),
766
966
  basePath: 'http://localhost:3000',
767
967
  scopes: createApiBindings,
968
+ defaults: { timeout: 30_000, headers: { 'X-Client-Version': '1.0.0' } },
768
969
  hooks: {
769
970
  onBeforeRequest(ctx) {
770
971
  ctx.request.headers = { ...ctx.request.headers, Authorization: `Bearer ${getToken()}` }
@@ -773,7 +974,17 @@ const client = createClient({
773
974
  },
774
975
  })
775
976
 
776
- const user = await client.users.GetUser({ pathParams: { id: '123' } })
977
+ // Per-call timeout + signal + hooks all in one options bag
978
+ const controller = new AbortController()
979
+ const user = await client.users.GetUser(
980
+ { pathParams: { id: '123' } },
981
+ {
982
+ timeout: 5000,
983
+ signal: controller.signal,
984
+ headers: { 'X-Request-Id': crypto.randomUUID() },
985
+ onAfterResponse(ctx) { log(ctx) },
986
+ },
987
+ )
777
988
 
778
989
  // Reach types via the namespace: Api.Users.GetUser.Params, Api.Errors.ProcedureError
779
990
  ```