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
@@ -0,0 +1,361 @@
1
+ import {
2
+ ProcedureError,
3
+ ProcedureValidationError,
4
+ ProcedureYieldValidationError,
5
+ } from '../../errors.js'
6
+ import type { TProcedureRegistration, TStreamProcedureRegistration } from '../../index.js'
7
+ import type { ErrorDoc } from '../types.js'
8
+
9
+ /** Either a regular or stream procedure registration — accepted by taxonomy callbacks. */
10
+ export type TAnyProcedureRegistration = TProcedureRegistration | TStreamProcedureRegistration
11
+
12
+ /**
13
+ * An entry in an {@link ErrorTaxonomy}. Describes how to recognize a thrown
14
+ * error at runtime and how to serialize it into an HTTP response.
15
+ *
16
+ * Exactly one of `class` or `match` must be provided.
17
+ */
18
+ export type ErrorTaxonomyEntry<TError = any, TBody = unknown> = {
19
+ /** `instanceof` discriminator. Mutually exclusive with `match`. */
20
+ class?: new (...args: any[]) => TError
21
+ /** Predicate discriminator — for 3rd-party errors that can't subclass a framework type. */
22
+ match?: (err: unknown) => err is TError
23
+ /** HTTP status code to send when this error is caught. */
24
+ statusCode: number
25
+ /** One-line description used by DocRegistry to document the error in the envelope. */
26
+ description?: string
27
+ /** Optional response-body JSON Schema — consumed by DocRegistry. */
28
+ schema?: Record<string, unknown>
29
+ /**
30
+ * Maps the caught error to a response body. When omitted, the body is
31
+ * `{ name: <key>, message: err.message }` where `<key>` is this entry's key.
32
+ * When the returned object lacks a `name` field, one is auto-injected —
33
+ * wire-protocol consistency (needed by the client dispatcher) is guaranteed.
34
+ */
35
+ toResponse?: (err: TError, meta: { key: string }) => TBody
36
+ /**
37
+ * Side effect on catch (logging, metrics). Awaited before the response is sent.
38
+ * `raw` is the framework-specific request context (Hono `Context` /
39
+ * `{ req, res }` for Express) — cast as needed.
40
+ */
41
+ onCatch?: (
42
+ err: TError,
43
+ ctx: { procedure: TAnyProcedureRegistration; key: string; raw: unknown }
44
+ ) => void | Promise<void>
45
+ }
46
+
47
+ /**
48
+ * A named collection of {@link ErrorTaxonomyEntry} values. First match wins at
49
+ * resolution time. `defineErrorTaxonomy` topologically sorts class-based
50
+ * entries so a subclass is always checked before its ancestors regardless of
51
+ * declaration order; predicate entries retain declared order.
52
+ */
53
+ export type ErrorTaxonomy = Record<string, ErrorTaxonomyEntry>
54
+
55
+ /**
56
+ * Identity helper that preserves literal inference and:
57
+ * 1. validates each entry has exactly one discriminator (`class` xor `match`);
58
+ * 2. topologically sorts `class:` entries so subclasses precede base classes.
59
+ *
60
+ * The sort is stable — entries unrelated by inheritance keep their declared
61
+ * order. Predicate entries (with `match:`) always keep declared order relative
62
+ * to class entries, so a predicate that's intentionally narrower can be placed
63
+ * before a class entry to take precedence.
64
+ */
65
+ export function defineErrorTaxonomy<T extends ErrorTaxonomy>(entries: T): T {
66
+ const pairs = Object.entries(entries)
67
+
68
+ for (const [key, entry] of pairs) {
69
+ const hasClass = entry.class !== undefined
70
+ const hasMatch = entry.match !== undefined
71
+ if (hasClass === hasMatch) {
72
+ throw new Error(
73
+ `Error taxonomy entry "${key}" must define exactly one of { class, match }.`
74
+ )
75
+ }
76
+ }
77
+
78
+ // Stable sort: subclass-of-the-other → -1, other-subclass-of-this → 1, else 0.
79
+ pairs.sort(([, a], [, b]) => {
80
+ if (!a.class || !b.class) return 0
81
+ if (a.class === b.class) return 0
82
+ if (a.class.prototype instanceof b.class) return -1
83
+ if (b.class.prototype instanceof a.class) return 1
84
+ return 0
85
+ })
86
+
87
+ return Object.fromEntries(pairs) as T
88
+ }
89
+
90
+ /**
91
+ * Default taxonomy covering framework error classes that can be thrown by a
92
+ * handler at request time. Layered after the user taxonomy during resolution.
93
+ *
94
+ * `ProcedureError` uses `match:` rather than `class:` so it matches only
95
+ * direct throws (e.g. from `ctx.error()`). When the core wraps a non-ProcedureError
96
+ * into a ProcedureError with `cause`, that wrapper falls through — the
97
+ * resolver unwraps the cause and the user taxonomy / `unknownError` sees the
98
+ * real thrown value.
99
+ */
100
+ export const defaultErrorTaxonomy = defineErrorTaxonomy({
101
+ ProcedureValidationError: {
102
+ class: ProcedureValidationError,
103
+ statusCode: 400,
104
+ description: 'Schema validation failed for the procedure input parameters.',
105
+ toResponse: (err) => ({
106
+ name: 'ProcedureValidationError' as const,
107
+ procedureName: err.procedureName,
108
+ message: err.message,
109
+ errors: err.errors,
110
+ }),
111
+ schema: {
112
+ type: 'object',
113
+ properties: {
114
+ name: { type: 'string', const: 'ProcedureValidationError' },
115
+ procedureName: { type: 'string' },
116
+ message: { type: 'string' },
117
+ errors: {
118
+ type: 'array',
119
+ items: {
120
+ type: 'object',
121
+ properties: {
122
+ instancePath: { type: 'string' },
123
+ message: { type: 'string' },
124
+ },
125
+ },
126
+ },
127
+ },
128
+ required: ['name', 'procedureName', 'message'],
129
+ },
130
+ },
131
+ ProcedureYieldValidationError: {
132
+ class: ProcedureYieldValidationError,
133
+ statusCode: 500,
134
+ description: 'Schema validation failed for a yielded value in a streaming procedure.',
135
+ toResponse: (err) => ({
136
+ name: 'ProcedureYieldValidationError' as const,
137
+ procedureName: err.procedureName,
138
+ message: err.message,
139
+ errors: err.errors,
140
+ }),
141
+ schema: {
142
+ type: 'object',
143
+ properties: {
144
+ name: { type: 'string', const: 'ProcedureYieldValidationError' },
145
+ procedureName: { type: 'string' },
146
+ message: { type: 'string' },
147
+ errors: {
148
+ type: 'array',
149
+ items: {
150
+ type: 'object',
151
+ properties: {
152
+ instancePath: { type: 'string' },
153
+ message: { type: 'string' },
154
+ },
155
+ },
156
+ },
157
+ },
158
+ required: ['name', 'procedureName', 'message'],
159
+ },
160
+ },
161
+ ProcedureError: {
162
+ match: (err): err is ProcedureError =>
163
+ err instanceof ProcedureError && (err as { cause?: unknown }).cause === undefined,
164
+ statusCode: 500,
165
+ description: 'An error thrown from within a procedure handler via ctx.error().',
166
+ toResponse: (err) => ({
167
+ name: 'ProcedureError' as const,
168
+ procedureName: err.procedureName,
169
+ message: err.message,
170
+ meta: err.meta,
171
+ }),
172
+ schema: {
173
+ type: 'object',
174
+ properties: {
175
+ name: { type: 'string', const: 'ProcedureError' },
176
+ procedureName: { type: 'string' },
177
+ message: { type: 'string' },
178
+ meta: { type: 'object' },
179
+ },
180
+ required: ['name', 'procedureName', 'message'],
181
+ },
182
+ },
183
+ })
184
+
185
+ /**
186
+ * Doc-only entry for `ProcedureRegistrationError`, which is thrown at
187
+ * procedure-definition time (never at request time) and therefore doesn't
188
+ * appear in the runtime taxonomy. Consumers still see it in the error catalog
189
+ * via `DocRegistry.defaultErrors()`.
190
+ */
191
+ export const PROCEDURE_REGISTRATION_ERROR_DOC: ErrorDoc = {
192
+ name: 'ProcedureRegistrationError',
193
+ statusCode: 500,
194
+ description:
195
+ 'An invalid schema or configuration was detected at procedure registration time.',
196
+ schema: {
197
+ type: 'object',
198
+ properties: {
199
+ name: { type: 'string', const: 'ProcedureRegistrationError' },
200
+ procedureName: { type: 'string' },
201
+ message: { type: 'string' },
202
+ },
203
+ required: ['name', 'procedureName', 'message'],
204
+ },
205
+ }
206
+
207
+ /**
208
+ * Converts a taxonomy into {@link ErrorDoc} objects suitable for a DocEnvelope.
209
+ *
210
+ * @internal Used by `DocRegistry` to merge taxonomy entries into the envelope.
211
+ * Consumers should pass their taxonomy directly to `new DocRegistry({ errors: taxonomy })`
212
+ * rather than calling this helper — the constructor handles the conversion.
213
+ */
214
+ export function taxonomyToErrorDocs(taxonomy: ErrorTaxonomy): ErrorDoc[] {
215
+ return Object.entries(taxonomy).map(([key, entry]) => ({
216
+ name: key,
217
+ statusCode: entry.statusCode,
218
+ description: entry.description ?? '',
219
+ schema: entry.schema,
220
+ }))
221
+ }
222
+
223
+ /**
224
+ * Configuration for the fallback "unknown" error handler — applied when no
225
+ * taxonomy entry (user or default) matches a thrown value.
226
+ */
227
+ export type UnknownErrorConfig = {
228
+ /** HTTP status code. Defaults to 500. */
229
+ statusCode?: number
230
+ /** Serializes the error into a response body. */
231
+ toResponse: (err: unknown) => unknown
232
+ /** Awaited before the response is sent. */
233
+ onCatch?: (
234
+ err: unknown,
235
+ ctx: { procedure: TAnyProcedureRegistration; raw: unknown }
236
+ ) => void | Promise<void>
237
+ }
238
+
239
+ /**
240
+ * Result of matching an error against a taxonomy chain. Consumers translate
241
+ * `{ statusCode, body }` into their framework's response primitive and must
242
+ * `await runOnCatch()` before sending.
243
+ */
244
+ export type ResolvedErrorResponse = {
245
+ statusCode: number
246
+ body: unknown
247
+ runOnCatch: () => Promise<void>
248
+ }
249
+
250
+ /**
251
+ * Injects `{ name: key }` when `rawBody` is an object without a `name` field.
252
+ * Guarantees wire-protocol consistency (client dispatchers discriminate on
253
+ * `body.name`) without forcing every `toResponse` to repeat it.
254
+ */
255
+ function ensureName(rawBody: unknown, key: string): unknown {
256
+ if (rawBody && typeof rawBody === 'object' && !('name' in (rawBody as object))) {
257
+ return { name: key, ...(rawBody as object) }
258
+ }
259
+ return rawBody
260
+ }
261
+
262
+ /**
263
+ * Matches a thrown value against the user taxonomy, then (if `includeDefaults`)
264
+ * the default taxonomy, then the `unknownError` config. Returns `null` when
265
+ * nothing matches — callers fall through to their builder's imperative
266
+ * `onError` callback and the hard default.
267
+ *
268
+ * The core wraps any non-ProcedureError thrown by a handler into a
269
+ * ProcedureError with `cause` set to the original. This resolver unwraps that:
270
+ * candidates are checked in the order `[cause, outer]` so a user taxonomy sees
271
+ * its own error classes rather than the wrapper. The default `ProcedureError`
272
+ * entry uses a `match:` predicate that excludes wrappers so they reach
273
+ * `unknownError` instead.
274
+ *
275
+ * Side effects (`onCatch`) are deferred into `runOnCatch` so the caller decides
276
+ * when to execute them relative to writing the response.
277
+ */
278
+ export function resolveErrorResponse(params: {
279
+ err: unknown
280
+ userTaxonomy?: ErrorTaxonomy
281
+ /** Whether to apply {@link defaultErrorTaxonomy}. Defaults to `true`. */
282
+ includeDefaults?: boolean
283
+ unknownError?: UnknownErrorConfig
284
+ procedure: TAnyProcedureRegistration
285
+ raw: unknown
286
+ }): ResolvedErrorResponse | null {
287
+ const {
288
+ err,
289
+ userTaxonomy,
290
+ includeDefaults = true,
291
+ unknownError,
292
+ procedure,
293
+ raw,
294
+ } = params
295
+
296
+ const wrappedCause =
297
+ err instanceof ProcedureError && (err as { cause?: unknown }).cause !== undefined
298
+ ? (err as { cause?: unknown }).cause
299
+ : undefined
300
+ // Most-specific candidate first so a matching user entry on `cause` wins over
301
+ // a matching entry on the outer wrapper.
302
+ const candidates: unknown[] =
303
+ wrappedCause !== undefined ? [wrappedCause, err] : [err]
304
+
305
+ const tryMatch = (tax: ErrorTaxonomy): ResolvedErrorResponse | null => {
306
+ for (const [key, entry] of Object.entries(tax)) {
307
+ for (const candidate of candidates) {
308
+ const matched = entry.class
309
+ ? candidate instanceof entry.class
310
+ : entry.match
311
+ ? entry.match(candidate)
312
+ : false
313
+ if (!matched) continue
314
+
315
+ const rawBody = entry.toResponse
316
+ ? entry.toResponse(candidate as any, { key })
317
+ : {
318
+ name: key,
319
+ message: candidate instanceof Error ? candidate.message : String(candidate),
320
+ }
321
+ const body = ensureName(rawBody, key)
322
+
323
+ return {
324
+ statusCode: entry.statusCode,
325
+ body,
326
+ runOnCatch: async () => {
327
+ if (entry.onCatch) {
328
+ await entry.onCatch(candidate as any, { procedure, key, raw })
329
+ }
330
+ },
331
+ }
332
+ }
333
+ }
334
+ return null
335
+ }
336
+
337
+ if (userTaxonomy) {
338
+ const hit = tryMatch(userTaxonomy)
339
+ if (hit) return hit
340
+ }
341
+
342
+ if (includeDefaults) {
343
+ const hit = tryMatch(defaultErrorTaxonomy)
344
+ if (hit) return hit
345
+ }
346
+
347
+ if (unknownError) {
348
+ const mostSpecific = candidates[0]
349
+ return {
350
+ statusCode: unknownError.statusCode ?? 500,
351
+ body: unknownError.toResponse(mostSpecific),
352
+ runOnCatch: async () => {
353
+ if (unknownError.onCatch) {
354
+ await unknownError.onCatch(mostSpecific, { procedure, raw })
355
+ }
356
+ },
357
+ }
358
+ }
359
+
360
+ return null
361
+ }
@@ -59,7 +59,7 @@ type ExpressRPCAppBuilderConfig = {
59
59
  onRequestStart?: (req: express.Request) => void
60
60
  onRequestEnd?: (req: express.Request, res: express.Response) => void
61
61
  onSuccess?: (procedure: TProcedureRegistration, req: express.Request, res: express.Response) => void
62
- error?: (procedure: TProcedureRegistration, req: express.Request, res: express.Response, error: Error) => void
62
+ onError?: (procedure: TProcedureRegistration, req: express.Request, res: express.Response, error: Error) => void
63
63
  }
64
64
  ```
65
65
 
@@ -70,7 +70,7 @@ type ExpressRPCAppBuilderConfig = {
70
70
  | `onRequestStart` | `(req) => void` | Called at start of each request |
71
71
  | `onRequestEnd` | `(req, res) => void` | Called after response finishes |
72
72
  | `onSuccess` | `(proc, req, res) => void` | Called on successful handler execution |
73
- | `error` | `(proc, req, res, err) => void` | Custom error handler |
73
+ | `onError` | `(proc, req, res, err) => void` | Imperative error handler (peer of `errors` taxonomy — see Error Handling) |
74
74
 
75
75
  ## Context Resolution
76
76
 
@@ -120,29 +120,24 @@ const RPC = Procedures<AppContext, RPCConfig>()
120
120
 
121
121
  ## Error Handling
122
122
 
123
- Custom error handler receives the procedure, request, response, and error:
123
+ Declare error classes via `defineErrorTaxonomy` and pass them to the builder's `errors` option. Handlers `throw` their classes; the builder auto-serializes to the configured status + body.
124
124
 
125
125
  ```typescript
126
- const builder = new ExpressRPCAppBuilder({
127
- onError: (procedure, req, res, error) => {
128
- console.error(`Error in ${procedure.name}:`, error)
129
-
130
- if (error instanceof ValidationError) {
131
- res.status(400).json({ error: error.message, code: 'VALIDATION_ERROR' })
132
- return
133
- }
126
+ import { defineErrorTaxonomy } from 'ts-procedures/express-rpc'
134
127
 
135
- if (error instanceof AuthError) {
136
- res.status(401).json({ error: 'Unauthorized', code: 'AUTH_ERROR' })
137
- return
138
- }
128
+ const appErrors = defineErrorTaxonomy({
129
+ AuthError: { class: AuthError, statusCode: 401 },
130
+ })
139
131
 
140
- res.status(500).json({ error: 'Internal server error' })
141
- }
132
+ new ExpressRPCAppBuilder({
133
+ errors: appErrors,
134
+ unknownError: { toResponse: () => ({ error: 'Internal server error' }) },
142
135
  })
143
136
  ```
144
137
 
145
- **Default error handling:** Returns `{ error: message }` with status 500.
138
+ Express-specific note: `onCatch` receives `raw: { req, res }` as its framework request context (cast at the use site).
139
+
140
+ Full contract (both peer error modes, `onRequestError` observer, per-route narrowing via `RPCConfig<keyof typeof appErrors & string>`): see **[docs/http-integrations.md § Error Handling](../../../../docs/http-integrations.md#error-handling)** — the canonical explanation shared across all four HTTP builders.
146
141
 
147
142
  ## Using Existing Express App
148
143
 
@@ -195,11 +190,15 @@ new ExpressRPCAppBuilder(config?: ExpressRPCAppBuilderConfig)
195
190
  ## TypeScript Types
196
191
 
197
192
  ```typescript
198
- import {
199
- ExpressRPCAppBuilder,
193
+ import { ExpressRPCAppBuilder, defineErrorTaxonomy } from 'ts-procedures/express-rpc'
194
+ import type {
200
195
  ExpressRPCAppBuilderConfig,
201
196
  RPCConfig,
202
- RPCHttpRouteDoc
197
+ RPCHttpRouteDoc,
198
+ ErrorTaxonomy,
199
+ ErrorTaxonomyEntry,
200
+ UnknownErrorConfig,
201
+ OnRequestErrorContext,
203
202
  } from 'ts-procedures/express-rpc'
204
203
  ```
205
204
 
@@ -271,10 +270,10 @@ builder
271
270
  const app = builder.build()
272
271
 
273
272
  // Generated routes:
274
- // POST /rpc/health/1
273
+ // POST /rpc/health/health-check/1
275
274
  // POST /rpc/system/version/get-version/1
276
- // POST /rpc/users/profile/get-user/1
277
- // POST /rpc/users/profile/get-user/2
275
+ // POST /rpc/users/profile/get-profile/1
276
+ // POST /rpc/users/profile/update-profile/2
278
277
 
279
278
  console.log('Routes:', builder.docs.map(d => d.path))
280
279
  app.listen(3000)
@@ -0,0 +1,103 @@
1
+ import { describe, expect, test, vi } from 'vitest'
2
+ import request from 'supertest'
3
+ import { Type } from 'typebox'
4
+ import { Procedures } from '../../../index.js'
5
+ import { RPCConfig } from '../../types.js'
6
+ import { ExpressRPCAppBuilder, defineErrorTaxonomy } from './index.js'
7
+
8
+ class UseCaseError extends Error {
9
+ constructor(
10
+ readonly externalMsg: string,
11
+ readonly internalMsg: string
12
+ ) {
13
+ super(externalMsg)
14
+ this.name = 'UseCaseError'
15
+ Object.setPrototypeOf(this, UseCaseError.prototype)
16
+ }
17
+ }
18
+
19
+ describe('ExpressRPCAppBuilder — error taxonomy', () => {
20
+ test('taxonomy catches user error thrown from RPC handler', async () => {
21
+ const errors = defineErrorTaxonomy({
22
+ UseCaseError: {
23
+ class: UseCaseError,
24
+ statusCode: 422,
25
+ toResponse: (err) => ({ name: 'UseCaseError', message: err.externalMsg }),
26
+ },
27
+ })
28
+ const RPC = Procedures<{}, RPCConfig>()
29
+ RPC.Create(
30
+ 'Boom',
31
+ { scope: 'test', version: 1, schema: { params: Type.Object({}) } },
32
+ async () => {
33
+ throw new UseCaseError('ext', 'int')
34
+ }
35
+ )
36
+ const app = new ExpressRPCAppBuilder({ errors }).register(RPC, () => ({})).build()
37
+ const res = await request(app).post('/test/boom/1').send({})
38
+ expect(res.status).toBe(422)
39
+ expect(res.body).toEqual({ name: 'UseCaseError', message: 'ext' })
40
+ })
41
+
42
+ test('unknownError catches unmapped errors', async () => {
43
+ const RPC = Procedures<{}, RPCConfig>()
44
+ RPC.Create(
45
+ 'Boom',
46
+ { scope: 'test', version: 1, schema: { params: Type.Object({}) } },
47
+ async () => {
48
+ throw new TypeError('db down')
49
+ }
50
+ )
51
+ const app = new ExpressRPCAppBuilder({
52
+ unknownError: { statusCode: 503, toResponse: () => ({ name: 'ServiceUnavailable' }) },
53
+ })
54
+ .register(RPC, () => ({}))
55
+ .build()
56
+ const res = await request(app).post('/test/boom/1').send({})
57
+ expect(res.status).toBe(503)
58
+ expect(res.body).toEqual({ name: 'ServiceUnavailable' })
59
+ })
60
+
61
+ test('onCatch receives { req, res } as raw context', async () => {
62
+ let rawSeen: any
63
+ const errors = defineErrorTaxonomy({
64
+ UseCaseError: {
65
+ class: UseCaseError,
66
+ statusCode: 422,
67
+ onCatch: (_err, ctx) => {
68
+ rawSeen = ctx.raw
69
+ },
70
+ },
71
+ })
72
+ const RPC = Procedures<{}, RPCConfig>()
73
+ RPC.Create(
74
+ 'Boom',
75
+ { scope: 'test', version: 1, schema: { params: Type.Object({}) } },
76
+ async () => {
77
+ throw new UseCaseError('ext', 'int')
78
+ }
79
+ )
80
+ const app = new ExpressRPCAppBuilder({ errors }).register(RPC, () => ({})).build()
81
+ await request(app).post('/test/boom/1').send({})
82
+ expect(rawSeen.req).toBeDefined()
83
+ expect(rawSeen.res).toBeDefined()
84
+ })
85
+
86
+ test('onError callback handles errors not matched by the taxonomy', async () => {
87
+ const onError = vi.fn((_p: any, _req: any, res: any) => {
88
+ res.status(418).json({ legacy: true })
89
+ })
90
+ const RPC = Procedures<{}, RPCConfig>()
91
+ RPC.Create(
92
+ 'Boom',
93
+ { scope: 'test', version: 1, schema: { params: Type.Object({}) } },
94
+ async () => {
95
+ throw new TypeError('legacy')
96
+ }
97
+ )
98
+ const app = new ExpressRPCAppBuilder({ onError }).register(RPC, () => ({})).build()
99
+ const res = await request(app).post('/test/boom/1').send({})
100
+ expect(res.status).toBe(418)
101
+ expect(onError).toHaveBeenCalledOnce()
102
+ })
103
+ })