ts-procedures 5.16.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 (146) 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 +163 -5
  6. package/agent_config/claude-code/skills/ts-procedures/patterns.md +169 -13
  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 +77 -12
  16. package/agent_config/cursor/cursorrules +77 -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 +21 -0
  61. package/build/implementations/http/doc-registry.js +51 -78
  62. package/build/implementations/http/doc-registry.js.map +1 -1
  63. package/build/implementations/http/doc-registry.test.js +8 -6
  64. package/build/implementations/http/doc-registry.test.js.map +1 -1
  65. package/build/implementations/http/error-taxonomy.d.ts +240 -0
  66. package/build/implementations/http/error-taxonomy.js +230 -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 +140 -0
  102. package/build/implementations/http/route-errors.test.js.map +1 -0
  103. package/build/implementations/types.d.ts +30 -2
  104. package/docs/client-and-codegen.md +105 -12
  105. package/docs/core.md +14 -5
  106. package/docs/http-integrations.md +135 -4
  107. package/docs/streaming.md +3 -1
  108. package/package.json +7 -2
  109. package/src/client/call.ts +10 -1
  110. package/src/client/error-dispatch.test.ts +72 -0
  111. package/src/client/error-dispatch.ts +27 -0
  112. package/src/client/fetch-adapter.ts +11 -5
  113. package/src/client/index.ts +9 -0
  114. package/src/client/stream.ts +14 -3
  115. package/src/client/typed-error-dispatch.test.ts +211 -0
  116. package/src/client/types.ts +42 -0
  117. package/src/codegen/e2e.test.ts +9 -4
  118. package/src/codegen/emit-client-runtime.ts +4 -0
  119. package/src/codegen/emit-errors.integration.test.ts +183 -0
  120. package/src/codegen/emit-errors.test.ts +91 -87
  121. package/src/codegen/emit-errors.ts +123 -41
  122. package/src/codegen/emit-index.test.ts +68 -24
  123. package/src/codegen/emit-index.ts +66 -4
  124. package/src/codegen/emit-scope.ts +124 -7
  125. package/src/codegen/pipeline.ts +25 -2
  126. package/src/implementations/http/README.md +19 -4
  127. package/src/implementations/http/doc-registry.test.ts +10 -6
  128. package/src/implementations/http/doc-registry.ts +63 -80
  129. package/src/implementations/http/error-taxonomy.test.ts +438 -0
  130. package/src/implementations/http/error-taxonomy.ts +337 -0
  131. package/src/implementations/http/express-rpc/README.md +21 -22
  132. package/src/implementations/http/express-rpc/error-taxonomy.test.ts +103 -0
  133. package/src/implementations/http/express-rpc/index.ts +75 -14
  134. package/src/implementations/http/hono-api/README.md +284 -0
  135. package/src/implementations/http/hono-api/error-taxonomy.test.ts +179 -0
  136. package/src/implementations/http/hono-api/index.ts +76 -1
  137. package/src/implementations/http/hono-rpc/README.md +18 -19
  138. package/src/implementations/http/hono-rpc/error-taxonomy.test.ts +82 -0
  139. package/src/implementations/http/hono-rpc/index.ts +65 -9
  140. package/src/implementations/http/hono-stream/README.md +44 -25
  141. package/src/implementations/http/hono-stream/error-taxonomy.test.ts +98 -0
  142. package/src/implementations/http/hono-stream/index.test.ts +54 -18
  143. package/src/implementations/http/hono-stream/index.ts +83 -13
  144. package/src/implementations/http/on-request-error.test.ts +201 -0
  145. package/src/implementations/http/route-errors.test.ts +177 -0
  146. package/src/implementations/types.ts +30 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ts-procedures",
3
- "version": "5.16.0",
3
+ "version": "6.0.0",
4
4
  "description": "A TypeScript RPC framework that creates type-safe, schema-validated procedure calls with a single function definition. Define your procedures once and get full type inference, runtime validation, and framework integration hooks.",
5
5
  "main": "build/exports.js",
6
6
  "types": "build/exports.d.ts",
@@ -12,7 +12,8 @@
12
12
  "scripts": {
13
13
  "build": "tsc",
14
14
  "lint": "npx eslint src/ --quiet",
15
- "prepublishOnly": "npm run lint && npm run build",
15
+ "check-docs": "bash scripts/check-docs-consistency.sh",
16
+ "prepublishOnly": "npm run lint && npm run build && npm run check-docs",
16
17
  "postinstall": "node ./agent_config/bin/postinstall.mjs",
17
18
  "test": "vitest run"
18
19
  },
@@ -45,6 +46,10 @@
45
46
  "types": "./build/implementations/http/doc-registry.d.ts",
46
47
  "import": "./build/implementations/http/doc-registry.js"
47
48
  },
49
+ "./http-errors": {
50
+ "types": "./build/implementations/http/error-taxonomy.d.ts",
51
+ "import": "./build/implementations/http/error-taxonomy.js"
52
+ },
48
53
  "./client": {
49
54
  "types": "./build/client/index.d.ts",
50
55
  "import": "./build/client/index.js"
@@ -2,10 +2,12 @@ import { buildAdapterRequest } from './request-builder.js'
2
2
  import { runBeforeRequest, runAfterResponse, runOnError } from './hooks.js'
3
3
  import { applyRequestOptions, resolveBasePath } from './resolve-options.js'
4
4
  import { ClientRequestError } from './errors.js'
5
+ import { dispatchTypedError } from './error-dispatch.js'
5
6
  import type {
6
7
  ClientAdapter,
7
8
  ClientHooks,
8
9
  CallDescriptor,
10
+ ErrorRegistry,
9
11
  ProcedureCallDefaults,
10
12
  ProcedureCallOptions,
11
13
  } from './types.js'
@@ -17,6 +19,7 @@ export interface ExecuteCallConfig {
17
19
  hooks: ClientHooks
18
20
  defaults?: ProcedureCallDefaults
19
21
  options?: ProcedureCallOptions
22
+ errorRegistry?: ErrorRegistry
20
23
  }
21
24
 
22
25
  /**
@@ -33,7 +36,7 @@ export interface ExecuteCallConfig {
33
36
  * 8. Return response.body as TResponse
34
37
  */
35
38
  export async function executeCall<TResponse>(config: ExecuteCallConfig): Promise<TResponse> {
36
- const { descriptor, basePath, adapter, hooks, defaults, options } = config
39
+ const { descriptor, basePath, adapter, hooks, defaults, options, errorRegistry } = config
37
40
 
38
41
  // 1. Build the initial request (path/query/body from descriptor)
39
42
  const resolvedBasePath = resolveBasePath(defaults, options, basePath)
@@ -73,6 +76,12 @@ export async function executeCall<TResponse>(config: ExecuteCallConfig): Promise
73
76
 
74
77
  // 7. Check status AFTER hooks (hooks may have swallowed the error by mutating status)
75
78
  if (response.status < 200 || response.status >= 300) {
79
+ const typed = dispatchTypedError(errorRegistry, response.body, {
80
+ status: response.status,
81
+ procedureName: descriptor.name,
82
+ scope: descriptor.scope,
83
+ })
84
+ if (typed) throw typed
76
85
  throw new ClientRequestError({
77
86
  status: response.status,
78
87
  headers: response.headers,
@@ -0,0 +1,72 @@
1
+ import { describe, expect, test } from 'vitest'
2
+ import { dispatchTypedError } from './error-dispatch.js'
3
+ import type { ErrorRegistry, ErrorResponseMeta } from './types.js'
4
+
5
+ class SyntheticError extends Error {
6
+ constructor(
7
+ message: string,
8
+ readonly props: string[]
9
+ ) {
10
+ super(message)
11
+ this.name = 'SyntheticError'
12
+ Object.setPrototypeOf(this, SyntheticError.prototype)
13
+ }
14
+ }
15
+
16
+ const meta: ErrorResponseMeta = { status: 422, procedureName: 'Test', scope: 'users' }
17
+
18
+ function makeRegistry(): ErrorRegistry {
19
+ return {
20
+ SyntheticError: {
21
+ fromResponse(body, m) {
22
+ const b = body as { message: string; props: string[] }
23
+ return new SyntheticError(b.message, b.props)
24
+ },
25
+ },
26
+ }
27
+ }
28
+
29
+ describe('dispatchTypedError', () => {
30
+ test('returns a typed error when body.name matches a registry key', () => {
31
+ const err = dispatchTypedError(
32
+ makeRegistry(),
33
+ { name: 'SyntheticError', message: 'bad', props: ['x'] },
34
+ meta
35
+ )
36
+ expect(err).toBeInstanceOf(SyntheticError)
37
+ expect(err).toBeInstanceOf(Error)
38
+ expect((err as SyntheticError).props).toEqual(['x'])
39
+ })
40
+
41
+ test('returns null when no registry is provided', () => {
42
+ const err = dispatchTypedError(undefined, { name: 'SyntheticError' }, meta)
43
+ expect(err).toBeNull()
44
+ })
45
+
46
+ test('returns null when body is not an object', () => {
47
+ expect(dispatchTypedError(makeRegistry(), 'oops', meta)).toBeNull()
48
+ expect(dispatchTypedError(makeRegistry(), null, meta)).toBeNull()
49
+ expect(dispatchTypedError(makeRegistry(), 42, meta)).toBeNull()
50
+ })
51
+
52
+ test('returns null when body.name is missing or not a string', () => {
53
+ expect(dispatchTypedError(makeRegistry(), {}, meta)).toBeNull()
54
+ expect(dispatchTypedError(makeRegistry(), { name: 123 }, meta)).toBeNull()
55
+ })
56
+
57
+ test('returns null when body.name does not match any registry key', () => {
58
+ expect(
59
+ dispatchTypedError(makeRegistry(), { name: 'UnregisteredError' }, meta)
60
+ ).toBeNull()
61
+ })
62
+
63
+ test('returns null when fromResponse returns a non-Error value', () => {
64
+ const registry: ErrorRegistry = {
65
+ Broken: {
66
+ // Intentionally returns a non-Error — defensive guard in dispatch.
67
+ fromResponse: () => 'not an error' as unknown as Error,
68
+ },
69
+ }
70
+ expect(dispatchTypedError(registry, { name: 'Broken' }, meta)).toBeNull()
71
+ })
72
+ })
@@ -0,0 +1,27 @@
1
+ import type { ErrorRegistry, ErrorResponseMeta } from './types.js'
2
+
3
+ /**
4
+ * Attempts to construct a typed error from the response body using the
5
+ * registry. Returns `null` when:
6
+ * - no registry is configured,
7
+ * - the body is not a plain object with a `name` string,
8
+ * - no registry key matches the body's `name`, or
9
+ * - `fromResponse` returns a non-Error value (defensive — registry entries
10
+ * are expected to return `Error` subclasses).
11
+ *
12
+ * Callers fall back to `ClientRequestError` when this returns `null`.
13
+ */
14
+ export function dispatchTypedError(
15
+ registry: ErrorRegistry | undefined,
16
+ body: unknown,
17
+ meta: ErrorResponseMeta
18
+ ): Error | null {
19
+ if (!registry) return null
20
+ if (!body || typeof body !== 'object') return null
21
+ const name = (body as { name?: unknown }).name
22
+ if (typeof name !== 'string') return null
23
+ const factory = registry[name]
24
+ if (!factory?.fromResponse) return null
25
+ const result = factory.fromResponse(body, meta)
26
+ return result instanceof Error ? result : null
27
+ }
@@ -174,17 +174,23 @@ export function createFetchAdapter(config?: FetchAdapterConfig): ClientAdapter {
174
174
  })
175
175
 
176
176
  const headers = extractHeaders(response)
177
+ const emptyBody: AsyncIterable<unknown> = {
178
+ [Symbol.asyncIterator]: async function* () {},
179
+ }
180
+
181
+ // Non-2xx responses on a stream endpoint are JSON, not SSE. Parse the
182
+ // body eagerly and surface it via errorBody so the client can dispatch
183
+ // a typed error (or fall back to ClientRequestError with a real body).
184
+ if (response.status < 200 || response.status >= 300) {
185
+ const errorBody = await parseResponseBody(response)
186
+ return { status: response.status, headers, body: emptyBody, errorBody }
187
+ }
177
188
 
178
189
  if (!response.body) {
179
- // No body — return an empty async iterable
180
- const emptyBody: AsyncIterable<unknown> = {
181
- [Symbol.asyncIterator]: async function* () {},
182
- }
183
190
  return { status: response.status, headers, body: emptyBody }
184
191
  }
185
192
 
186
193
  const body = parseSseStream(response.body as ReadableStream<Uint8Array>)
187
-
188
194
  return { status: response.status, headers, body }
189
195
  },
190
196
  }
@@ -30,6 +30,7 @@ export function createClient<TScopes>(config: CreateClientConfig<TScopes>): TSco
30
30
  basePath,
31
31
  hooks: globalHooks = {},
32
32
  defaults: globalDefaults = {},
33
+ errorRegistry,
33
34
  scopes,
34
35
  } = config
35
36
 
@@ -38,6 +39,7 @@ export function createClient<TScopes>(config: CreateClientConfig<TScopes>): TSco
38
39
  adapter,
39
40
  hooks: globalHooks,
40
41
  defaults: globalDefaults,
42
+ errorRegistry,
41
43
 
42
44
  call<TResponse>(
43
45
  descriptor: CallDescriptor,
@@ -50,6 +52,7 @@ export function createClient<TScopes>(config: CreateClientConfig<TScopes>): TSco
50
52
  hooks: globalHooks,
51
53
  defaults: globalDefaults,
52
54
  options,
55
+ errorRegistry,
53
56
  })
54
57
  },
55
58
 
@@ -79,6 +82,7 @@ export function createClient<TScopes>(config: CreateClientConfig<TScopes>): TSco
79
82
  hooks: globalHooks,
80
83
  defaults: globalDefaults,
81
84
  options,
85
+ errorRegistry,
82
86
  })
83
87
  } catch (err) {
84
88
  rejectResult(err)
@@ -126,8 +130,13 @@ export type {
126
130
  ProcedureCallOptions,
127
131
  CreateClientConfig,
128
132
  RequestMeta,
133
+ ErrorRegistry,
134
+ ErrorFactory,
135
+ ErrorResponseMeta,
129
136
  } from './types.js'
130
137
 
138
+ export { dispatchTypedError } from './error-dispatch.js'
139
+
131
140
  export { ClientRequestError, ClientPathParamError, ClientStreamError } from './errors.js'
132
141
 
133
142
  export { createTypedStream } from './stream.js'
@@ -2,9 +2,11 @@ import { buildAdapterRequest } from './request-builder.js'
2
2
  import { runBeforeRequest, runAfterResponse, runOnError } from './hooks.js'
3
3
  import { applyRequestOptions, resolveBasePath } from './resolve-options.js'
4
4
  import { ClientRequestError } from './errors.js'
5
+ import { dispatchTypedError } from './error-dispatch.js'
5
6
  import type {
6
7
  ClientAdapter,
7
8
  ClientHooks,
9
+ ErrorRegistry,
8
10
  StreamDescriptor,
9
11
  TypedStream,
10
12
  AdapterResponse,
@@ -100,6 +102,7 @@ export interface ExecuteStreamConfig {
100
102
  hooks: ClientHooks
101
103
  defaults?: ProcedureCallDefaults
102
104
  options?: ProcedureCallOptions
105
+ errorRegistry?: ErrorRegistry
103
106
  }
104
107
 
105
108
  /**
@@ -118,7 +121,7 @@ export interface ExecuteStreamConfig {
118
121
  export async function executeStream<TYield, TReturn = void>(
119
122
  config: ExecuteStreamConfig,
120
123
  ): Promise<TypedStream<TYield, TReturn>> {
121
- const { descriptor, basePath, adapter, hooks, defaults, options } = config
124
+ const { descriptor, basePath, adapter, hooks, defaults, options, errorRegistry } = config
122
125
 
123
126
  // 1. Build the initial request
124
127
  const resolvedBasePath = resolveBasePath(defaults, options, basePath)
@@ -149,11 +152,13 @@ export async function executeStream<TYield, TReturn = void>(
149
152
  throw err
150
153
  }
151
154
 
152
- // Build an AdapterResponse shape for the hooks (body is null for streams at this point)
155
+ // Build an AdapterResponse shape for the hooks. For success the body is null
156
+ // (the actual data flows through the async iterable); for non-2xx the adapter
157
+ // eagerly parses the JSON response body and surfaces it via `errorBody`.
153
158
  const responseForHooks: AdapterResponse = {
154
159
  status: streamResponse.status,
155
160
  headers: streamResponse.headers,
156
- body: null,
161
+ body: streamResponse.errorBody ?? null,
157
162
  }
158
163
 
159
164
  // 6. Run after-response hooks immediately (before iteration)
@@ -165,6 +170,12 @@ export async function executeStream<TYield, TReturn = void>(
165
170
 
166
171
  // 7. Check status after hooks (hooks may mutate responseForHooks.status)
167
172
  if (responseForHooks.status < 200 || responseForHooks.status >= 300) {
173
+ const typed = dispatchTypedError(errorRegistry, responseForHooks.body, {
174
+ status: responseForHooks.status,
175
+ procedureName: descriptor.name,
176
+ scope: descriptor.scope,
177
+ })
178
+ if (typed) throw typed
168
179
  throw new ClientRequestError({
169
180
  status: responseForHooks.status,
170
181
  headers: responseForHooks.headers,
@@ -0,0 +1,211 @@
1
+ /* eslint-disable @typescript-eslint/no-empty-object-type */
2
+ import { describe, expect, test } from 'vitest'
3
+ import { Type } from 'typebox'
4
+ import { Procedures } from '../index.js'
5
+ import { APIConfig } from '../implementations/types.js'
6
+ import { HonoAPIAppBuilder, defineErrorTaxonomy } from '../implementations/http/hono-api/index.js'
7
+ import { createClient } from './index.js'
8
+ import { ClientRequestError } from './errors.js'
9
+ import type { ClientAdapter, ErrorRegistry } from './types.js'
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Error taxonomy + simulated generated error classes
13
+ // ---------------------------------------------------------------------------
14
+
15
+ class UseCaseError extends Error {
16
+ constructor(
17
+ readonly externalMsg: string,
18
+ readonly internalMsg: string
19
+ ) {
20
+ super(externalMsg)
21
+ this.name = 'UseCaseError'
22
+ Object.setPrototypeOf(this, UseCaseError.prototype)
23
+ }
24
+ }
25
+
26
+ const appErrors = defineErrorTaxonomy({
27
+ UseCaseError: {
28
+ class: UseCaseError,
29
+ statusCode: 422,
30
+ toResponse: (err) => ({ message: err.externalMsg }),
31
+ },
32
+ })
33
+
34
+ // Simulates what the codegen emits: a runtime class + a registry entry the
35
+ // client uses for dispatch. In real usage this comes from the generated
36
+ // `_errors.ts`; here we inline it to test the client-side dispatch path end
37
+ // to end without invoking the codegen pipeline.
38
+ class ApiUseCaseError extends Error {
39
+ readonly status: number
40
+ readonly procedureName: string
41
+ readonly scope: string
42
+ readonly body: { name: 'UseCaseError'; message: string }
43
+ constructor(args: {
44
+ message: string
45
+ status: number
46
+ procedureName: string
47
+ scope: string
48
+ body: { name: 'UseCaseError'; message: string }
49
+ }) {
50
+ super(args.message)
51
+ this.name = 'UseCaseError'
52
+ this.status = args.status
53
+ this.procedureName = args.procedureName
54
+ this.scope = args.scope
55
+ this.body = args.body
56
+ Object.setPrototypeOf(this, ApiUseCaseError.prototype)
57
+ }
58
+
59
+ static fromResponse(
60
+ body: unknown,
61
+ meta: { status: number; procedureName: string; scope: string }
62
+ ): ApiUseCaseError {
63
+ const b = body as { name: 'UseCaseError'; message: string }
64
+ return new ApiUseCaseError({
65
+ message: b.message,
66
+ status: meta.status,
67
+ procedureName: meta.procedureName,
68
+ scope: meta.scope,
69
+ body: b,
70
+ })
71
+ }
72
+ }
73
+
74
+ const errorRegistry: ErrorRegistry = { UseCaseError: ApiUseCaseError }
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // Tiny adapter that routes through the in-memory Hono app (no real network)
78
+ // ---------------------------------------------------------------------------
79
+
80
+ interface HonoAppLike {
81
+ request(
82
+ url: string,
83
+ init: { method?: string; headers?: Record<string, string>; body?: string }
84
+ ): Response | Promise<Response>
85
+ }
86
+
87
+ function honoAdapter(app: HonoAppLike): ClientAdapter {
88
+ return {
89
+ async request(req) {
90
+ const res = await Promise.resolve(
91
+ app.request(req.url, {
92
+ method: req.method,
93
+ headers: req.headers,
94
+ body: req.body !== undefined ? JSON.stringify(req.body) : undefined,
95
+ })
96
+ )
97
+ const headers: Record<string, string> = {}
98
+ res.headers.forEach((v, k) => (headers[k] = v))
99
+ const body = await res.json().catch(() => null)
100
+ return { status: res.status, headers, body }
101
+ },
102
+ async stream() {
103
+ throw new Error('not used')
104
+ },
105
+ }
106
+ }
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // Tests
110
+ // ---------------------------------------------------------------------------
111
+
112
+ describe('typed error dispatch — end-to-end', () => {
113
+ function buildApp() {
114
+ const API = Procedures<{}, APIConfig>()
115
+ API.Create(
116
+ 'GetUser',
117
+ {
118
+ path: '/users/:id',
119
+ method: 'get',
120
+ schema: {
121
+ input: { pathParams: Type.Object({ id: Type.String() }) },
122
+ returnType: Type.Object({ id: Type.String() }),
123
+ },
124
+ },
125
+ async (_ctx, { pathParams }) => {
126
+ if (pathParams.id === 'missing') {
127
+ throw new UseCaseError('User not found', `no user with id=${pathParams.id}`)
128
+ }
129
+ return { id: pathParams.id }
130
+ }
131
+ )
132
+ return new HonoAPIAppBuilder({ errors: appErrors }).register(API, () => ({})).build()
133
+ }
134
+
135
+ test('server-thrown UseCaseError arrives on client as a typed class instance', async () => {
136
+ const app = buildApp()
137
+ const api = createClient({
138
+ adapter: honoAdapter(app),
139
+ basePath: '',
140
+ errorRegistry,
141
+ scopes: (client) => ({
142
+ getUser: (id: string) =>
143
+ client.call<{ id: string }>({
144
+ name: 'GetUser',
145
+ scope: 'users',
146
+ path: '/users/:id',
147
+ method: 'get',
148
+ kind: 'api',
149
+ params: { pathParams: { id } },
150
+ }),
151
+ }),
152
+ })
153
+
154
+ await expect(api.getUser('missing')).rejects.toBeInstanceOf(ApiUseCaseError)
155
+ try {
156
+ await api.getUser('missing')
157
+ } catch (err) {
158
+ expect(err).toBeInstanceOf(ApiUseCaseError)
159
+ expect(err).toBeInstanceOf(Error)
160
+ expect((err as ApiUseCaseError).status).toBe(422)
161
+ expect((err as ApiUseCaseError).procedureName).toBe('GetUser')
162
+ expect((err as ApiUseCaseError).message).toBe('User not found')
163
+ expect((err as ApiUseCaseError).body.name).toBe('UseCaseError')
164
+ }
165
+ })
166
+
167
+ test('unregistered error body falls back to ClientRequestError', async () => {
168
+ const app = buildApp()
169
+ // Omit the registry so dispatch can't match; client sees the raw
170
+ // transport error instead of a typed class.
171
+ const api = createClient({
172
+ adapter: honoAdapter(app),
173
+ basePath: '',
174
+ scopes: (client) => ({
175
+ getUser: (id: string) =>
176
+ client.call<{ id: string }>({
177
+ name: 'GetUser',
178
+ scope: 'users',
179
+ path: '/users/:id',
180
+ method: 'get',
181
+ kind: 'api',
182
+ params: { pathParams: { id } },
183
+ }),
184
+ }),
185
+ })
186
+
187
+ await expect(api.getUser('missing')).rejects.toBeInstanceOf(ClientRequestError)
188
+ })
189
+
190
+ test('success responses are not disturbed by dispatch logic', async () => {
191
+ const app = buildApp()
192
+ const api = createClient({
193
+ adapter: honoAdapter(app),
194
+ basePath: '',
195
+ errorRegistry,
196
+ scopes: (client) => ({
197
+ getUser: (id: string) =>
198
+ client.call<{ id: string }>({
199
+ name: 'GetUser',
200
+ scope: 'users',
201
+ path: '/users/:id',
202
+ method: 'get',
203
+ kind: 'api',
204
+ params: { pathParams: { id } },
205
+ }),
206
+ }),
207
+ })
208
+
209
+ await expect(api.getUser('u_42')).resolves.toEqual({ id: 'u_42' })
210
+ })
211
+ })
@@ -60,6 +60,12 @@ export interface AdapterStreamResponse {
60
60
  status: number
61
61
  headers: Record<string, string>
62
62
  body: AsyncIterable<unknown>
63
+ /**
64
+ * Populated when `status` is non-2xx — the parsed response body. Surfaced so
65
+ * `executeStream` can dispatch typed errors via the error registry instead
66
+ * of always falling back to `ClientRequestError` with `body: null`.
67
+ */
68
+ errorBody?: unknown
63
69
  }
64
70
 
65
71
  // ── Hooks ────────────────────────────────────────────────
@@ -148,6 +154,33 @@ export interface ProcedureCallDefaults {
148
154
  */
149
155
  export interface ProcedureCallOptions extends ProcedureCallDefaults, ClientHooks {}
150
156
 
157
+ // ── Error Registry ───────────────────────────────────────
158
+
159
+ /**
160
+ * Metadata attached to a typed error at construction. Supplies the transport
161
+ * context (status, procedure, scope) that isn't part of the response body.
162
+ */
163
+ export interface ErrorResponseMeta {
164
+ status: number
165
+ procedureName: string
166
+ scope: string
167
+ }
168
+
169
+ /**
170
+ * A factory for a typed error class — constructed from the response body plus
171
+ * transport metadata. Generated error classes expose this as a static method.
172
+ */
173
+ export interface ErrorFactory {
174
+ fromResponse(body: unknown, meta: ErrorResponseMeta): Error
175
+ }
176
+
177
+ /**
178
+ * Maps `body.name` values (taxonomy keys) to error class factories. When the
179
+ * client sees a non-2xx response whose body has a `name` matching a registry
180
+ * entry, it throws the typed error instead of a generic `ClientRequestError`.
181
+ */
182
+ export type ErrorRegistry = Record<string, ErrorFactory>
183
+
151
184
  // ── Client Instance ──────────────────────────────────────
152
185
 
153
186
  export interface ClientInstance {
@@ -155,6 +188,8 @@ export interface ClientInstance {
155
188
  adapter: ClientAdapter
156
189
  hooks: ClientHooks
157
190
  defaults: ProcedureCallDefaults
191
+ /** Optional registry for runtime dispatch of typed errors by `body.name`. */
192
+ errorRegistry?: ErrorRegistry
158
193
  call<TResponse>(descriptor: CallDescriptor, options?: ProcedureCallOptions): Promise<TResponse>
159
194
  stream<TYield, TReturn>(descriptor: StreamDescriptor, options?: ProcedureCallOptions): TypedStream<TYield, TReturn>
160
195
  }
@@ -172,4 +207,11 @@ export interface CreateClientConfig<TScopes> {
172
207
  * fires first cancels the request).
173
208
  */
174
209
  defaults?: ProcedureCallDefaults
210
+ /**
211
+ * Optional error-dispatch registry. When a non-2xx response body has a
212
+ * `name` field matching a registry key, the client throws the typed error
213
+ * constructed via that entry's `fromResponse`. When absent or when no key
214
+ * matches, falls back to `ClientRequestError` (transport error shape).
215
+ */
216
+ errorRegistry?: ErrorRegistry
175
217
  }
@@ -339,12 +339,13 @@ describe('E2E: generateClient full pipeline', () => {
339
339
  expect(existsSync(join(tmpDir, '_errors.ts'))).toBe(true)
340
340
  })
341
341
 
342
- it('_errors.ts contains ProcedureError type', async () => {
342
+ it('_errors.ts contains a runtime class for ProcedureError', async () => {
343
343
  tmpDir = makeTmpDir()
344
344
  await generateClient({ envelope, outDir: tmpDir })
345
345
 
346
346
  const content = readFileSync(join(tmpDir, '_errors.ts'), 'utf-8')
347
- expect(content).toContain('export type ProcedureError =')
347
+ expect(content).toContain('export class ProcedureError')
348
+ expect(content).toContain('static fromResponse(')
348
349
  })
349
350
 
350
351
  it('_errors.ts contains the service-prefixed ProcedureErrorUnion', async () => {
@@ -357,12 +358,16 @@ describe('E2E: generateClient full pipeline', () => {
357
358
  expect(content).toContain('ProcedureValidationError')
358
359
  })
359
360
 
360
- it('index.ts does not import _errors when namespaceTypes is off', async () => {
361
+ it('index.ts imports the _errors registry as a runtime value when errors are present', async () => {
362
+ // PR 3 change: the error registry is imported as a value (not `import
363
+ // type`) so `createApiClient` can wire it into `createClient` regardless
364
+ // of `namespaceTypes`.
361
365
  tmpDir = makeTmpDir()
362
366
  await generateClient({ envelope, outDir: tmpDir })
363
367
 
364
368
  const content = readFileSync(join(tmpDir, 'index.ts'), 'utf-8')
365
- expect(content).not.toContain("from './_errors'")
369
+ expect(content).toContain("import * as _errorsModule from './_errors'")
370
+ expect(content).toContain('errorRegistry: _errorsModule.ApiErrorRegistry')
366
371
  })
367
372
 
368
373
  it('index.ts folds errors into the service namespace when namespaceTypes is on', async () => {
@@ -20,6 +20,9 @@ const TYPES_IMPORT = `import type {
20
20
  ProcedureCallOptions,
21
21
  CreateClientConfig,
22
22
  RequestMeta,
23
+ ErrorRegistry,
24
+ ErrorFactory,
25
+ ErrorResponseMeta,
23
26
  } from './_types'`
24
27
 
25
28
  /**
@@ -28,6 +31,7 @@ const TYPES_IMPORT = `import type {
28
31
  */
29
32
  const SOURCE_FILES = [
30
33
  'errors.ts',
34
+ 'error-dispatch.ts',
31
35
  'request-builder.ts',
32
36
  'resolve-options.ts',
33
37
  'hooks.ts',