ts-procedures 5.16.0 → 6.0.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.
Files changed (147) 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 +87 -19
  5. package/agent_config/claude-code/skills/ts-procedures/api-reference.md +162 -16
  6. package/agent_config/claude-code/skills/ts-procedures/patterns.md +179 -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 +22 -15
  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 +78 -12
  16. package/agent_config/cursor/cursorrules +78 -12
  17. package/build/client/call.d.ts +2 -1
  18. package/build/client/call.js +9 -1
  19. package/build/client/call.js.map +1 -1
  20. package/build/client/error-dispatch.d.ts +13 -0
  21. package/build/client/error-dispatch.js +26 -0
  22. package/build/client/error-dispatch.js.map +1 -0
  23. package/build/client/error-dispatch.test.d.ts +1 -0
  24. package/build/client/error-dispatch.test.js +56 -0
  25. package/build/client/error-dispatch.test.js.map +1 -0
  26. package/build/client/fetch-adapter.js +10 -4
  27. package/build/client/fetch-adapter.js.map +1 -1
  28. package/build/client/index.d.ts +2 -1
  29. package/build/client/index.js +5 -1
  30. package/build/client/index.js.map +1 -1
  31. package/build/client/stream.d.ts +2 -1
  32. package/build/client/stream.js +13 -3
  33. package/build/client/stream.js.map +1 -1
  34. package/build/client/typed-error-dispatch.test.d.ts +1 -0
  35. package/build/client/typed-error-dispatch.test.js +168 -0
  36. package/build/client/typed-error-dispatch.test.js.map +1 -0
  37. package/build/client/types.d.ts +37 -0
  38. package/build/codegen/e2e.test.js +9 -4
  39. package/build/codegen/e2e.test.js.map +1 -1
  40. package/build/codegen/emit-client-runtime.js +4 -0
  41. package/build/codegen/emit-client-runtime.js.map +1 -1
  42. package/build/codegen/emit-errors.d.ts +17 -6
  43. package/build/codegen/emit-errors.integration.test.d.ts +1 -0
  44. package/build/codegen/emit-errors.integration.test.js +162 -0
  45. package/build/codegen/emit-errors.integration.test.js.map +1 -0
  46. package/build/codegen/emit-errors.js +50 -39
  47. package/build/codegen/emit-errors.js.map +1 -1
  48. package/build/codegen/emit-errors.test.js +75 -78
  49. package/build/codegen/emit-errors.test.js.map +1 -1
  50. package/build/codegen/emit-index.d.ts +7 -0
  51. package/build/codegen/emit-index.js +26 -4
  52. package/build/codegen/emit-index.js.map +1 -1
  53. package/build/codegen/emit-index.test.js +55 -23
  54. package/build/codegen/emit-index.test.js.map +1 -1
  55. package/build/codegen/emit-scope.d.ts +8 -0
  56. package/build/codegen/emit-scope.js +82 -7
  57. package/build/codegen/emit-scope.js.map +1 -1
  58. package/build/codegen/pipeline.js +22 -2
  59. package/build/codegen/pipeline.js.map +1 -1
  60. package/build/implementations/http/doc-registry.d.ts +17 -1
  61. package/build/implementations/http/doc-registry.js +47 -79
  62. package/build/implementations/http/doc-registry.js.map +1 -1
  63. package/build/implementations/http/doc-registry.test.js +149 -16
  64. package/build/implementations/http/doc-registry.test.js.map +1 -1
  65. package/build/implementations/http/error-taxonomy.d.ts +249 -0
  66. package/build/implementations/http/error-taxonomy.js +252 -0
  67. package/build/implementations/http/error-taxonomy.js.map +1 -0
  68. package/build/implementations/http/error-taxonomy.test.d.ts +1 -0
  69. package/build/implementations/http/error-taxonomy.test.js +399 -0
  70. package/build/implementations/http/error-taxonomy.test.js.map +1 -0
  71. package/build/implementations/http/express-rpc/error-taxonomy.test.d.ts +1 -0
  72. package/build/implementations/http/express-rpc/error-taxonomy.test.js +83 -0
  73. package/build/implementations/http/express-rpc/error-taxonomy.test.js.map +1 -0
  74. package/build/implementations/http/express-rpc/index.d.ts +39 -8
  75. package/build/implementations/http/express-rpc/index.js +39 -8
  76. package/build/implementations/http/express-rpc/index.js.map +1 -1
  77. package/build/implementations/http/hono-api/error-taxonomy.test.d.ts +1 -0
  78. package/build/implementations/http/hono-api/error-taxonomy.test.js +137 -0
  79. package/build/implementations/http/hono-api/error-taxonomy.test.js.map +1 -0
  80. package/build/implementations/http/hono-api/index.d.ts +38 -1
  81. package/build/implementations/http/hono-api/index.js +32 -0
  82. package/build/implementations/http/hono-api/index.js.map +1 -1
  83. package/build/implementations/http/hono-rpc/error-taxonomy.test.d.ts +1 -0
  84. package/build/implementations/http/hono-rpc/error-taxonomy.test.js +64 -0
  85. package/build/implementations/http/hono-rpc/error-taxonomy.test.js.map +1 -0
  86. package/build/implementations/http/hono-rpc/index.d.ts +34 -7
  87. package/build/implementations/http/hono-rpc/index.js +31 -4
  88. package/build/implementations/http/hono-rpc/index.js.map +1 -1
  89. package/build/implementations/http/hono-stream/error-taxonomy.test.d.ts +1 -0
  90. package/build/implementations/http/hono-stream/error-taxonomy.test.js +87 -0
  91. package/build/implementations/http/hono-stream/error-taxonomy.test.js.map +1 -0
  92. package/build/implementations/http/hono-stream/index.d.ts +40 -3
  93. package/build/implementations/http/hono-stream/index.js +37 -10
  94. package/build/implementations/http/hono-stream/index.js.map +1 -1
  95. package/build/implementations/http/hono-stream/index.test.js +45 -18
  96. package/build/implementations/http/hono-stream/index.test.js.map +1 -1
  97. package/build/implementations/http/on-request-error.test.d.ts +1 -0
  98. package/build/implementations/http/on-request-error.test.js +173 -0
  99. package/build/implementations/http/on-request-error.test.js.map +1 -0
  100. package/build/implementations/http/route-errors.test.d.ts +1 -0
  101. package/build/implementations/http/route-errors.test.js +139 -0
  102. package/build/implementations/http/route-errors.test.js.map +1 -0
  103. package/build/implementations/types.d.ts +43 -3
  104. package/docs/client-and-codegen.md +105 -12
  105. package/docs/core.md +14 -5
  106. package/docs/http-integrations.md +138 -5
  107. package/docs/streaming.md +3 -1
  108. package/docs/superpowers/plans/2026-04-24-doc-registry-simplification.md +886 -0
  109. package/package.json +7 -2
  110. package/src/client/call.ts +10 -1
  111. package/src/client/error-dispatch.test.ts +72 -0
  112. package/src/client/error-dispatch.ts +27 -0
  113. package/src/client/fetch-adapter.ts +11 -5
  114. package/src/client/index.ts +9 -0
  115. package/src/client/stream.ts +14 -3
  116. package/src/client/typed-error-dispatch.test.ts +211 -0
  117. package/src/client/types.ts +42 -0
  118. package/src/codegen/e2e.test.ts +9 -4
  119. package/src/codegen/emit-client-runtime.ts +4 -0
  120. package/src/codegen/emit-errors.integration.test.ts +183 -0
  121. package/src/codegen/emit-errors.test.ts +91 -87
  122. package/src/codegen/emit-errors.ts +123 -41
  123. package/src/codegen/emit-index.test.ts +68 -24
  124. package/src/codegen/emit-index.ts +66 -4
  125. package/src/codegen/emit-scope.ts +124 -7
  126. package/src/codegen/pipeline.ts +25 -2
  127. package/src/implementations/http/README.md +21 -7
  128. package/src/implementations/http/doc-registry.test.ts +164 -16
  129. package/src/implementations/http/doc-registry.ts +58 -82
  130. package/src/implementations/http/error-taxonomy.test.ts +438 -0
  131. package/src/implementations/http/error-taxonomy.ts +361 -0
  132. package/src/implementations/http/express-rpc/README.md +23 -24
  133. package/src/implementations/http/express-rpc/error-taxonomy.test.ts +103 -0
  134. package/src/implementations/http/express-rpc/index.ts +75 -14
  135. package/src/implementations/http/hono-api/README.md +284 -0
  136. package/src/implementations/http/hono-api/error-taxonomy.test.ts +179 -0
  137. package/src/implementations/http/hono-api/index.ts +76 -1
  138. package/src/implementations/http/hono-rpc/README.md +20 -21
  139. package/src/implementations/http/hono-rpc/error-taxonomy.test.ts +82 -0
  140. package/src/implementations/http/hono-rpc/index.ts +65 -9
  141. package/src/implementations/http/hono-stream/README.md +44 -25
  142. package/src/implementations/http/hono-stream/error-taxonomy.test.ts +98 -0
  143. package/src/implementations/http/hono-stream/index.test.ts +54 -18
  144. package/src/implementations/http/hono-stream/index.ts +83 -13
  145. package/src/implementations/http/on-request-error.test.ts +201 -0
  146. package/src/implementations/http/route-errors.test.ts +176 -0
  147. package/src/implementations/types.ts +43 -3
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. Pass your taxonomy directly: `new DocRegistry({ errors: appErrors })` — framework defaults are auto-merged and deduped. For errors outside your taxonomy (middleware, infrastructure, doc-only), chain `.documentError(...docs)`.
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, per-call options (timeout/signal/headers/basePath/meta), client-level defaults, typed RequestMeta augmentation, CLI options |
174
+ | `docs/http-integrations.md` | Express RPC, Hono RPC/Stream/API builders, **error taxonomy (canonical)**, DocRegistry (unified constructor, `.documentError()`) |
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
 
@@ -474,13 +476,13 @@ import { DocRegistry } from 'ts-procedures/http-docs'
474
476
  const docs = new DocRegistry({
475
477
  basePath: '/api',
476
478
  headers: [{ name: 'Authorization', description: 'Bearer token' }],
477
- errors: DocRegistry.defaultErrors(),
479
+ errors: appErrors, // your ErrorTaxonomy — framework defaults auto-merged
478
480
  }).from(rpcBuilder).from(apiBuilder).from(streamBuilder)
479
481
 
480
482
  app.get('/docs', (c) => c.json(docs.toJSON()))
481
483
  ```
482
484
 
483
- **Why:** `DocRegistry` provides a typed `DocEnvelope`, includes `defaultErrors()` with JSON Schemas for all 4 procedure error types, reads docs lazily (order-independent), and supports filtering and transformation via `toJSON()` options.
485
+ **Why:** `DocRegistry` provides a typed `DocEnvelope`, auto-merges framework error defaults (`ProcedureError`, `ProcedureValidationError`, `ProcedureYieldValidationError`, `ProcedureRegistrationError`) with JSON Schemas, reads docs lazily (order-independent), and supports filtering and transformation via `toJSON()` options.
484
486
 
485
487
  ---
486
488
 
@@ -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,119 @@ 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
+ Taxonomy-to-doc conversion is handled automatically by `DocRegistry` when you pass your taxonomy to the constructor. There is no public helper.
299
+
300
+ ### resolveErrorResponse(params)
301
+
302
+ ```typescript
303
+ function resolveErrorResponse(params: {
304
+ err: unknown
305
+ userTaxonomy?: ErrorTaxonomy
306
+ includeDefaults?: boolean // default true
307
+ unknownError?: UnknownErrorConfig
308
+ procedure: TAnyProcedureRegistration
309
+ raw: unknown
310
+ }): { statusCode: number; body: unknown; runOnCatch: () => Promise<void> } | null
311
+ ```
312
+
313
+ 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.
314
+
315
+ ### UnknownErrorConfig
316
+
317
+ ```typescript
318
+ type UnknownErrorConfig = {
319
+ statusCode?: number // default 500
320
+ toResponse: (err: unknown) => unknown
321
+ onCatch?: (
322
+ err: unknown,
323
+ ctx: { procedure: TAnyProcedureRegistration; raw: unknown }
324
+ ) => void | Promise<void>
325
+ }
326
+ ```
327
+
328
+ ---
329
+
330
+ ## Client Error Registry API
331
+
332
+ Exported from `ts-procedures/client`. Generated clients hook their `${Service}ErrorRegistry` into `createClient` so non-2xx responses arrive as typed class instances.
333
+
334
+ ### ErrorRegistry / ErrorFactory / ErrorResponseMeta
335
+
336
+ ```typescript
337
+ interface ErrorResponseMeta { status: number; procedureName: string; scope: string }
338
+ interface ErrorFactory { fromResponse(body: unknown, meta: ErrorResponseMeta): Error }
339
+ type ErrorRegistry = Record<string, ErrorFactory>
340
+ ```
341
+
342
+ ### dispatchTypedError(registry, body, meta)
343
+
344
+ ```typescript
345
+ function dispatchTypedError(
346
+ registry: ErrorRegistry | undefined,
347
+ body: unknown,
348
+ meta: ErrorResponseMeta
349
+ ): Error | null
350
+ ```
351
+
352
+ Returns a typed error instance when:
353
+ - `registry` is defined, AND
354
+ - `body` is an object with a string `name`, AND
355
+ - `registry[body.name].fromResponse(body, meta)` returns an `Error` subclass.
356
+
357
+ Otherwise returns `null`; callers fall back to `ClientRequestError`.
358
+
359
+ ### CreateClientConfig.errorRegistry
360
+
361
+ ```typescript
362
+ interface CreateClientConfig<TScopes> {
363
+ // ...existing fields
364
+ errorRegistry?: ErrorRegistry
365
+ }
366
+ ```
367
+
368
+ Threaded into both `executeCall` and `executeStream`. The generated `create${Service}Client(config)` factory wires this automatically.
369
+
370
+ ---
371
+
259
372
  ## Schema Utilities
260
373
 
261
374
  ### extractJsonSchema(libSchema)
@@ -308,7 +421,11 @@ class ExpressRPCAppBuilder {
308
421
  onRequestStart?: (req: express.Request) => void
309
422
  onRequestEnd?: (req: express.Request, res: express.Response) => void
310
423
  onSuccess?: (procedure: TProcedureRegistration, req: express.Request, res: express.Response) => void
424
+ // Error handling — two peer modes plus a cross-cutting observer.
425
+ errors?: ErrorTaxonomy
426
+ unknownError?: UnknownErrorConfig
311
427
  onError?: (procedure: TProcedureRegistration, req: express.Request, res: express.Response, error: Error) => void
428
+ onRequestError?: (ctx: OnRequestErrorContext) => void | Promise<void>
312
429
  })
313
430
 
314
431
  register<TFactory>(
@@ -326,6 +443,8 @@ class ExpressRPCAppBuilder {
326
443
  }
327
444
  ```
328
445
 
446
+ `OnRequestErrorContext` for Express: `{ err: unknown; procedure: TProcedureRegistration; raw: { req, res } }`.
447
+
329
448
  ### Route Path
330
449
 
331
450
  `POST {pathPrefix}/{scope}/{kebab-name}/{version}`
@@ -357,7 +476,11 @@ class HonoRPCAppBuilder {
357
476
  onRequestStart?: (c: Context) => void
358
477
  onRequestEnd?: (c: Context) => void
359
478
  onSuccess?: (procedure: TProcedureRegistration, c: Context) => void
479
+ // Error handling — two peer modes plus a cross-cutting observer.
480
+ errors?: ErrorTaxonomy
481
+ unknownError?: UnknownErrorConfig
360
482
  onError?: (procedure: TProcedureRegistration, c: Context, error: Error) => Response | Promise<Response>
483
+ onRequestError?: (ctx: OnRequestErrorContext) => void | Promise<void>
361
484
  })
362
485
 
363
486
  register<TFactory>(
@@ -372,6 +495,8 @@ class HonoRPCAppBuilder {
372
495
  }
373
496
  ```
374
497
 
498
+ `OnRequestErrorContext` for Hono RPC: `{ err: unknown; procedure: TProcedureRegistration; raw: Context }`.
499
+
375
500
  ### Signal Injection
376
501
 
377
502
  Uses `c.req.raw.signal` — the native Request signal from the Hono context.
@@ -392,7 +517,10 @@ class HonoStreamAppBuilder<TErrorData = unknown> {
392
517
  onRequestEnd?: (c: Context) => void
393
518
  onStreamStart?: (procedure: TStreamProcedureRegistration, c: Context, streamMode: StreamMode) => void
394
519
  onStreamEnd?: (procedure: TStreamProcedureRegistration, c: Context, streamMode: StreamMode) => void
395
- onPreStreamError?: (procedure: TStreamProcedureRegistration, c: Context, error: Error) => Response | Promise<Response>
520
+ errors?: ErrorTaxonomy
521
+ unknownError?: UnknownErrorConfig
522
+ onError?: (procedure: TStreamProcedureRegistration, c: Context, error: Error) => Response | Promise<Response> // renamed from onPreStreamError in v6
523
+ onRequestError?: (ctx: { err: unknown; procedure: TStreamProcedureRegistration; raw: Context }) => void | Promise<void>
396
524
  onMidStreamError?: (procedure: TStreamProcedureRegistration, c: Context, error: Error) => MidStreamErrorResult<TErrorData> | undefined
397
525
  })
398
526
 
@@ -451,8 +579,8 @@ type MidStreamErrorResult<TErrorData = unknown> = {
451
579
 
452
580
  ### Error Handling
453
581
 
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`.
582
+ - **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`.
583
+ - **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
584
 
457
585
  ---
458
586
 
@@ -469,7 +597,11 @@ class HonoAPIAppBuilder {
469
597
  onRequestStart?: (c: Context) => void
470
598
  onRequestEnd?: (c: Context) => void
471
599
  onSuccess?: (procedure: TProcedureRegistration, c: Context) => void
600
+ // Error handling — two peer modes plus a cross-cutting observer.
601
+ errors?: ErrorTaxonomy
602
+ unknownError?: UnknownErrorConfig
472
603
  onError?: (procedure: TProcedureRegistration, c: Context, error: Error) => Response | Promise<Response>
604
+ onRequestError?: (ctx: OnRequestErrorContext) => void | Promise<void>
473
605
  })
474
606
 
475
607
  register<TFactory>(
@@ -484,6 +616,8 @@ class HonoAPIAppBuilder {
484
616
  }
485
617
  ```
486
618
 
619
+ `OnRequestErrorContext` for Hono API: `{ err: unknown; procedure: TProcedureRegistration; raw: Context }`.
620
+
487
621
  ### Route Path
488
622
 
489
623
  Developer-defined via `APIConfig.path` — no auto-generation. Supports Hono path params (`:id`).
@@ -553,9 +687,12 @@ Uses `c.req.raw.signal` — native Request signal from Hono context.
553
687
  ### RPCConfig
554
688
 
555
689
  ```typescript
556
- interface RPCConfig {
690
+ // Generic over valid taxonomy keys. Default TErrorKey = string (permissive).
691
+ // Narrow via `RPCConfig<keyof typeof appErrors & string>` for compile-time typo protection.
692
+ interface RPCConfig<TErrorKey extends string = string> {
557
693
  scope: string | string[]
558
694
  version: number
695
+ errors?: TErrorKey[] // Taxonomy keys this procedure may emit (informational; populates DocEnvelope).
559
696
  }
560
697
  ```
561
698
 
@@ -570,6 +707,7 @@ interface RPCHttpRouteDoc extends RPCConfig {
570
707
  body?: Record<string, unknown>
571
708
  response?: Record<string, unknown>
572
709
  }
710
+ errors?: string[] // Per-route subset, populated from config.errors.
573
711
  }
574
712
  ```
575
713
 
@@ -586,16 +724,20 @@ interface StreamHttpRouteDoc extends RPCConfig {
586
724
  yieldType?: Record<string, unknown>
587
725
  returnType?: Record<string, unknown>
588
726
  }
727
+ errors?: string[]
589
728
  }
590
729
  ```
591
730
 
592
731
  ### APIConfig
593
732
 
594
733
  ```typescript
595
- interface APIConfig {
734
+ // Generic over valid taxonomy keys — same pattern as RPCConfig.
735
+ interface APIConfig<TErrorKey extends string = string> {
596
736
  path: string
597
737
  method: 'get' | 'post' | 'put' | 'delete' | 'patch' | 'head'
598
738
  successStatus?: number
739
+ scope?: string
740
+ errors?: TErrorKey[]
599
741
  }
600
742
  ```
601
743
 
@@ -612,6 +754,7 @@ interface APIHttpRouteDoc extends APIConfig {
612
754
  headers?: Record<string, unknown>
613
755
  response?: Record<string, unknown>
614
756
  }
757
+ errors?: string[]
615
758
  }
616
759
  ```
617
760
 
@@ -646,19 +789,22 @@ import type { DocEnvelope, DocRegistryConfig, DocRegistryOutputOptions, HeaderDo
646
789
  ```
647
790
 
648
791
  ```typescript
792
+ import { DocRegistry } from 'ts-procedures/http-docs'
793
+ import type { DocEnvelope, DocRegistryConfig, DocRegistryOutputOptions, HeaderDoc, ErrorDoc, DocSource } from 'ts-procedures/http-docs'
794
+
795
+ interface DocRegistryConfig {
796
+ basePath?: string
797
+ headers?: HeaderDoc[]
798
+ errors?: ErrorTaxonomy | ErrorDoc[] // polymorphic — pass taxonomy or raw docs
799
+ includeDefaults?: boolean // default: true (auto-merges framework defaults)
800
+ }
801
+
649
802
  class DocRegistry {
650
- constructor(config?: {
651
- basePath?: string
652
- headers?: HeaderDoc[]
653
- errors?: ErrorDoc[]
654
- })
803
+ constructor(config?: DocRegistryConfig)
655
804
 
656
805
  from(source: DocSource<AnyHttpRouteDoc>): this
657
-
658
- toJSON<T = DocEnvelope>(options?: {
659
- filter?: (route: AnyHttpRouteDoc) => boolean
660
- transform?: (envelope: DocEnvelope) => T
661
- }): T
806
+ documentError(...docs: ErrorDoc[]): this
807
+ toJSON<T = DocEnvelope>(options?: DocRegistryOutputOptions<T>): T
662
808
 
663
809
  static defaultErrors(): ErrorDoc[]
664
810
  }
@@ -717,7 +863,7 @@ type AnyHttpRouteDoc = RPCHttpRouteDoc | APIHttpRouteDoc | StreamHttpRouteDoc
717
863
 
718
864
  - `from()` stores a **reference** to the builder — routes are read lazily at `toJSON()` time, so builders can be registered before or after `.build()`
719
865
  - `toJSON()` collects routes via `flatMap(source => source.docs)`, applies optional `filter`, builds envelope, then applies optional `transform`
720
- - `defaultErrors()` returns 4 `ErrorDoc` entries describing `ProcedureError` (500), `ProcedureValidationError` (400), `ProcedureYieldValidationError` (500), `ProcedureRegistrationError` (500) — each with a JSON Schema for the error response body shape
866
+ - `defaultErrors()` returns 4 `ErrorDoc` entries describing `ProcedureError` (500), `ProcedureValidationError` (400), `ProcedureYieldValidationError` (500), `ProcedureRegistrationError` (500) — each with a JSON Schema for the error response body shape. Most consumers do not need to call this directly; the `DocRegistry` constructor auto-merges these unless `includeDefaults: false` is passed.
721
867
  - `JSON.stringify(registry)` works directly (calls `toJSON()` implicitly)
722
868
 
723
869
  ---