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
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Verifies the generated error classes actually work at runtime — instantiated,
3
+ * thrown, and `instanceof`-checkable. We do this by generating a client from a
4
+ * synthetic DocEnvelope, writing it to a temp dir, dynamically importing the
5
+ * generated `_errors.ts`, and exercising the classes.
6
+ */
7
+ import { describe, expect, it } from 'vitest'
8
+ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
9
+ import { tmpdir } from 'node:os'
10
+ import { join } from 'node:path'
11
+ import { execSync } from 'node:child_process'
12
+ import { generateClient } from './index.js'
13
+ import type { DocEnvelope } from '../implementations/types.js'
14
+
15
+ describe('generated _errors.ts — runtime behavior', () => {
16
+ const envelope: DocEnvelope = {
17
+ basePath: '/api',
18
+ headers: [],
19
+ errors: [
20
+ {
21
+ name: 'UseCaseError',
22
+ statusCode: 422,
23
+ description: 'Business rule violation.',
24
+ schema: {
25
+ type: 'object',
26
+ properties: {
27
+ name: { type: 'string', const: 'UseCaseError' },
28
+ message: { type: 'string' },
29
+ },
30
+ required: ['name', 'message'],
31
+ },
32
+ },
33
+ {
34
+ name: 'AuthError',
35
+ statusCode: 401,
36
+ description: 'Authentication required.',
37
+ schema: {
38
+ type: 'object',
39
+ properties: {
40
+ name: { type: 'string', const: 'AuthError' },
41
+ message: { type: 'string' },
42
+ },
43
+ required: ['name', 'message'],
44
+ },
45
+ },
46
+ ],
47
+ routes: [
48
+ {
49
+ kind: 'api',
50
+ name: 'Ping',
51
+ scope: 'default',
52
+ path: '/ping',
53
+ fullPath: '/api/ping',
54
+ method: 'get',
55
+ jsonSchema: { response: { type: 'object', properties: {} } },
56
+ },
57
+ ],
58
+ }
59
+
60
+ function makeTmp(): string {
61
+ return mkdtempSync(join(tmpdir(), 'ts-proc-errors-'))
62
+ }
63
+
64
+ it('emits classes that extend a shared base and are instanceof-checkable', async () => {
65
+ const outDir = makeTmp()
66
+ try {
67
+ await generateClient({ envelope, outDir, namespaceTypes: false, selfContained: true })
68
+
69
+ // Compile the generated files with tsc so the classes become runnable JS.
70
+ const tsconfig = {
71
+ compilerOptions: {
72
+ target: 'ES2022',
73
+ module: 'ESNext',
74
+ moduleResolution: 'bundler',
75
+ strict: true,
76
+ outDir: './out',
77
+ },
78
+ include: ['_types.ts', '_client.ts', '_errors.ts', 'index.ts', 'default.ts'],
79
+ }
80
+ writeFileSync(join(outDir, 'tsconfig.json'), JSON.stringify(tsconfig, null, 2))
81
+
82
+ const tscPath = join(process.cwd(), 'node_modules/.bin/tsc')
83
+ try {
84
+ execSync(`${tscPath} --project ${join(outDir, 'tsconfig.json')}`, { stdio: 'pipe' })
85
+ } catch (e) {
86
+ const err = e as { stdout?: Buffer; stderr?: Buffer }
87
+ const stdout = err.stdout?.toString() ?? ''
88
+ const stderr = err.stderr?.toString() ?? ''
89
+ throw new Error(`tsc failed in ${outDir}:\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`)
90
+ }
91
+
92
+ // Dynamic import of the compiled output.
93
+ const errorsUrl = `file://${join(outDir, 'out', '_errors.js')}`
94
+ const mod = await import(errorsUrl)
95
+
96
+ // Default serviceName is 'Api' so base class is ApiProcedureError and
97
+ // registry is ApiErrorRegistry.
98
+ expect(typeof mod.ApiProcedureError).toBe('function')
99
+ expect(typeof mod.UseCaseError).toBe('function')
100
+ expect(typeof mod.AuthError).toBe('function')
101
+
102
+ // Classes extend the base — `instanceof` works in both directions.
103
+ const useCase = mod.UseCaseError.fromResponse(
104
+ { name: 'UseCaseError', message: 'boom' },
105
+ { status: 422, procedureName: 'DoThing', scope: 'things' }
106
+ )
107
+ expect(useCase).toBeInstanceOf(mod.UseCaseError)
108
+ expect(useCase).toBeInstanceOf(mod.ApiProcedureError)
109
+ expect(useCase).toBeInstanceOf(Error)
110
+ expect(useCase.status).toBe(422)
111
+ expect(useCase.procedureName).toBe('DoThing')
112
+ expect(useCase.scope).toBe('things')
113
+ expect(useCase.message).toBe('boom')
114
+ expect(useCase.body.message).toBe('boom')
115
+
116
+ // Registry is a plain object keyed by class name.
117
+ expect(mod.ApiErrorRegistry.UseCaseError).toBe(mod.UseCaseError)
118
+ expect(mod.ApiErrorRegistry.AuthError).toBe(mod.AuthError)
119
+
120
+ // Subclasses don't accidentally share identity (distinct constructors).
121
+ const auth = mod.AuthError.fromResponse(
122
+ { name: 'AuthError', message: 'nope' },
123
+ { status: 401, procedureName: 'Secret', scope: 'auth' }
124
+ )
125
+ expect(auth).toBeInstanceOf(mod.AuthError)
126
+ expect(auth).not.toBeInstanceOf(mod.UseCaseError)
127
+ } finally {
128
+ rmSync(outDir, { recursive: true, force: true })
129
+ }
130
+ }, 30000)
131
+
132
+ it('namespaceTypes: true wraps classes in a namespace and registry is reachable via qualified name', async () => {
133
+ const outDir = makeTmp()
134
+ try {
135
+ await generateClient({
136
+ envelope,
137
+ outDir,
138
+ namespaceTypes: true,
139
+ selfContained: true,
140
+ serviceName: 'Demo',
141
+ })
142
+
143
+ const tsconfig = {
144
+ compilerOptions: {
145
+ target: 'ES2022',
146
+ module: 'ESNext',
147
+ moduleResolution: 'bundler',
148
+ strict: true,
149
+ outDir: './out',
150
+ },
151
+ include: ['_types.ts', '_client.ts', '_errors.ts', 'index.ts', 'default.ts'],
152
+ }
153
+ writeFileSync(join(outDir, 'tsconfig.json'), JSON.stringify(tsconfig, null, 2))
154
+
155
+ const tscPath = join(process.cwd(), 'node_modules/.bin/tsc')
156
+ try {
157
+ execSync(`${tscPath} --project ${join(outDir, 'tsconfig.json')}`, { stdio: 'pipe' })
158
+ } catch (e) {
159
+ const err = e as { stdout?: Buffer; stderr?: Buffer }
160
+ const stdout = err.stdout?.toString() ?? ''
161
+ const stderr = err.stderr?.toString() ?? ''
162
+ throw new Error(`tsc failed in ${outDir}:\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`)
163
+ }
164
+
165
+ const errorsUrl = `file://${join(outDir, 'out', '_errors.js')}`
166
+ const mod = await import(errorsUrl)
167
+
168
+ const ns = mod.DemoErrors
169
+ expect(typeof ns.UseCaseError).toBe('function')
170
+ expect(typeof ns.DemoErrorRegistry).toBe('object')
171
+ expect(ns.DemoErrorRegistry.UseCaseError).toBe(ns.UseCaseError)
172
+
173
+ const useCase = ns.UseCaseError.fromResponse(
174
+ { name: 'UseCaseError', message: 'x' },
175
+ { status: 422, procedureName: 'P', scope: 's' }
176
+ )
177
+ expect(useCase).toBeInstanceOf(ns.UseCaseError)
178
+ expect(useCase).toBeInstanceOf(ns.DemoProcedureError)
179
+ } finally {
180
+ rmSync(outDir, { recursive: true, force: true })
181
+ }
182
+ }, 30000)
183
+ })
@@ -49,36 +49,66 @@ const errorDocWithoutSchema: ErrorDoc = {
49
49
  // ---------------------------------------------------------------------------
50
50
 
51
51
  describe('emitErrorsFile', () => {
52
- it('generates types for errors with schemas', async () => {
52
+ it('generates a runtime class per error with schema', async () => {
53
53
  const result = await emitErrorsFile([procedureErrorDoc, validationErrorDoc])
54
54
  expect(result).toBeDefined()
55
- expect(result).toContain('export type ProcedureError =')
56
- expect(result).toContain('export type ProcedureValidationError =')
55
+ expect(result).toContain('export class ProcedureError')
56
+ expect(result).toContain('export class ProcedureValidationError')
57
57
  })
58
58
 
59
- it('generates ProcedureErrorUnion discriminated union', async () => {
60
- const result = await emitErrorsFile([procedureErrorDoc, validationErrorDoc])
59
+ it('emits a body type for each class', async () => {
60
+ const result = await emitErrorsFile([procedureErrorDoc])
61
+ expect(result).toContain('export type ProcedureErrorBody =')
62
+ })
63
+
64
+ it('each class has a static fromResponse factory', async () => {
65
+ const result = await emitErrorsFile([procedureErrorDoc])
66
+ expect(result).toContain('static fromResponse(')
67
+ expect(result).toContain("name: 'ProcedureError'")
68
+ })
69
+
70
+ it('emits a shared base class the errors extend', async () => {
71
+ const result = await emitErrorsFile([procedureErrorDoc])
61
72
  expect(result).toBeDefined()
62
- expect(result).toContain('export type ProcedureErrorUnion = ProcedureError | ProcedureValidationError')
73
+ expect(result).toMatch(/export class ProcedureErrorBase<TBody = unknown> extends Error/)
74
+ expect(result).toContain('extends ProcedureErrorBase<ProcedureErrorBody>')
75
+ })
76
+
77
+ it('emits a discriminated union type', async () => {
78
+ const result = await emitErrorsFile([procedureErrorDoc, validationErrorDoc])
79
+ expect(result).toContain(
80
+ 'export type ProcedureErrorUnion = ProcedureError | ProcedureValidationError'
81
+ )
82
+ })
83
+
84
+ it('emits a runtime registry keyed by class name', async () => {
85
+ const result = await emitErrorsFile([procedureErrorDoc, validationErrorDoc])
86
+ expect(result).toContain('export const ErrorRegistry = {')
87
+ expect(result).toMatch(/ProcedureError,/)
88
+ expect(result).toMatch(/ProcedureValidationError,/)
63
89
  })
64
90
 
65
91
  it('includes JSDoc with statusCode and description', async () => {
66
92
  const result = await emitErrorsFile([procedureErrorDoc])
67
- expect(result).toBeDefined()
68
- expect(result).toContain('/** An error thrown from within a procedure handler via ctx.error(). (HTTP 500) */')
93
+ expect(result).toContain(
94
+ '/** An error thrown from within a procedure handler via ctx.error(). (HTTP 500) */'
95
+ )
69
96
  })
70
97
 
71
- it('includes JSDoc before the type declaration', async () => {
98
+ it('includes JSDoc before the concrete class declaration', async () => {
72
99
  const result = await emitErrorsFile([procedureErrorDoc])
73
- expect(result).toBeDefined()
74
- const jsdocIdx = result!.indexOf('/** An error thrown from within a procedure handler via ctx.error().')
75
- const typeIdx = result!.indexOf('export type ProcedureError =')
76
- expect(jsdocIdx).toBeLessThan(typeIdx)
100
+ const jsdocIdx = result!.indexOf(
101
+ '/** An error thrown from within a procedure handler via ctx.error().'
102
+ )
103
+ // Match only the concrete class (ProcedureError), not the base class
104
+ // (ProcedureErrorBase which is a prefix-collision).
105
+ const concreteClassIdx = result!.indexOf('export class ProcedureError extends')
106
+ expect(jsdocIdx).toBeGreaterThan(-1)
107
+ expect(concreteClassIdx).toBeGreaterThan(jsdocIdx)
77
108
  })
78
109
 
79
110
  it('includes the auto-generated header comment', async () => {
80
111
  const result = await emitErrorsFile([procedureErrorDoc])
81
- expect(result).toBeDefined()
82
112
  expect(result).toContain('// Auto-generated by ts-procedures-codegen — do not edit')
83
113
  })
84
114
 
@@ -87,116 +117,90 @@ describe('emitErrorsFile', () => {
87
117
  expect(result).toBeUndefined()
88
118
  })
89
119
 
90
- it('returns undefined for empty errors array', async () => {
120
+ it('returns undefined for an empty errors array', async () => {
91
121
  const result = await emitErrorsFile([])
92
122
  expect(result).toBeUndefined()
93
123
  })
94
124
 
95
- it('skips errors without schema, includes those with schema', async () => {
96
- const result = await emitErrorsFile([procedureErrorDoc, errorDocWithoutSchema, validationErrorDoc])
97
- expect(result).toBeDefined()
98
- expect(result).toContain('export type ProcedureError =')
99
- expect(result).toContain('export type ProcedureValidationError =')
100
- // UnknownError has no schema, so it should not appear as a type
101
- expect(result).not.toContain('export type UnknownError =')
102
- // But the union should only include the ones with schemas
103
- expect(result).toContain('ProcedureError | ProcedureValidationError')
104
- expect(result).not.toContain('UnknownError')
105
- })
106
-
107
- it('union type has single type when only one error has schema', async () => {
108
- const result = await emitErrorsFile([procedureErrorDoc])
109
- expect(result).toBeDefined()
110
- expect(result).toContain('export type ProcedureErrorUnion = ProcedureError')
111
- })
112
-
113
- it('union type does not include errros without schemas', async () => {
114
- const result = await emitErrorsFile([procedureErrorDoc, errorDocWithoutSchema])
115
- expect(result).toBeDefined()
125
+ it('skips errors without schema, emits classes for the rest', async () => {
126
+ const result = await emitErrorsFile([
127
+ procedureErrorDoc,
128
+ errorDocWithoutSchema,
129
+ validationErrorDoc,
130
+ ])
131
+ expect(result).toContain('export class ProcedureError')
132
+ expect(result).toContain('export class ProcedureValidationError')
133
+ expect(result).not.toContain('export class UnknownError')
116
134
  expect(result).not.toContain('UnknownError')
117
- expect(result).toContain('export type ProcedureErrorUnion = ProcedureError')
118
135
  })
119
136
 
120
- it('respects ajscOpts passed to jsonSchemaToTypeString', async () => {
121
- // Just verify it does not crash when options are passed — behavior is tested in emit-types.test.ts
137
+ it('accepts ajsc options without crashing', async () => {
122
138
  const result = await emitErrorsFile([procedureErrorDoc], { ajsc: { enumStyle: 'union' } })
123
139
  expect(result).toBeDefined()
124
- expect(result).toContain('export type ProcedureError =')
140
+ expect(result).toContain('export class ProcedureError')
125
141
  })
126
142
 
127
143
  describe('serviceName', () => {
128
- it('uses Errors namespace by default', async () => {
129
- const result = await emitErrorsFile([procedureErrorDoc, validationErrorDoc], { namespaceTypes: true })
144
+ it('uses Errors namespace by default in namespace mode', async () => {
145
+ const result = await emitErrorsFile([procedureErrorDoc], { namespaceTypes: true })
130
146
  expect(result).toContain('export namespace Errors {')
131
147
  })
132
148
 
133
- it('prefixes namespace with serviceName when provided', async () => {
134
- const result = await emitErrorsFile([procedureErrorDoc, validationErrorDoc], { namespaceTypes: true, serviceName: 'Auth' })
149
+ it('prefixes namespace with serviceName', async () => {
150
+ const result = await emitErrorsFile([procedureErrorDoc], {
151
+ namespaceTypes: true,
152
+ serviceName: 'Auth',
153
+ })
135
154
  expect(result).toContain('export namespace AuthErrors {')
136
155
  expect(result).not.toContain('export namespace Errors {')
137
156
  })
138
157
 
139
- it('PascalCases a kebab-case serviceName for namespace', async () => {
140
- const result = await emitErrorsFile([procedureErrorDoc], { namespaceTypes: true, serviceName: 'user-service' })
158
+ it('PascalCases a kebab-case serviceName', async () => {
159
+ const result = await emitErrorsFile([procedureErrorDoc], {
160
+ namespaceTypes: true,
161
+ serviceName: 'user-service',
162
+ })
141
163
  expect(result).toContain('export namespace UserServiceErrors {')
142
164
  })
143
165
 
144
- it('does not affect namespace when namespaceTypes is false', async () => {
145
- const result = await emitErrorsFile([procedureErrorDoc], { namespaceTypes: false, serviceName: 'Auth' })
146
- expect(result).toBeDefined()
147
- expect(result).not.toContain('namespace')
148
- })
149
-
150
- it('prefixes ProcedureErrorUnion in flat mode when serviceName is set', async () => {
151
- const result = await emitErrorsFile([procedureErrorDoc, validationErrorDoc], { namespaceTypes: false, serviceName: 'Auth' })
152
- expect(result).toBeDefined()
153
- expect(result).toContain('export type AuthProcedureErrorUnion = ProcedureError | ProcedureValidationError')
154
- expect(result).not.toContain('export type ProcedureErrorUnion =')
155
- })
156
-
157
- it('prefixes ProcedureErrorUnion inside namespace when serviceName is set', async () => {
158
- const result = await emitErrorsFile([procedureErrorDoc], { namespaceTypes: true, serviceName: 'Auth' })
159
- expect(result).toBeDefined()
160
- expect(result).toContain('AuthProcedureErrorUnion')
161
- expect(result).not.toMatch(/\bexport type ProcedureErrorUnion\b/)
166
+ it('prefixes base class, union, and registry when serviceName is set', async () => {
167
+ const result = await emitErrorsFile([procedureErrorDoc, validationErrorDoc], {
168
+ serviceName: 'Auth',
169
+ })
170
+ expect(result).toMatch(/export class AuthProcedureError<TBody = unknown> extends Error/)
171
+ expect(result).toContain(
172
+ 'export type AuthProcedureErrorUnion = ProcedureError | ProcedureValidationError'
173
+ )
174
+ expect(result).toContain('export const AuthErrorRegistry = {')
162
175
  })
163
176
 
164
- it('leaves ProcedureErrorUnion unprefixed when no serviceName', async () => {
165
- const result = await emitErrorsFile([procedureErrorDoc], { namespaceTypes: false })
166
- expect(result).toBeDefined()
177
+ it('leaves names unprefixed when no serviceName', async () => {
178
+ const result = await emitErrorsFile([procedureErrorDoc])
179
+ expect(result).toMatch(/export class ProcedureErrorBase<TBody = unknown> extends Error/)
167
180
  expect(result).toContain('export type ProcedureErrorUnion = ProcedureError')
181
+ expect(result).toContain('export const ErrorRegistry = {')
168
182
  })
169
183
  })
170
184
 
171
185
  describe('namespaceTypes', () => {
172
- it('wraps error types in export namespace Errors', async () => {
173
- const result = await emitErrorsFile([procedureErrorDoc, validationErrorDoc], { namespaceTypes: true })
174
- expect(result).toBeDefined()
186
+ it('wraps classes and registry in export namespace', async () => {
187
+ const result = await emitErrorsFile([procedureErrorDoc, validationErrorDoc], {
188
+ namespaceTypes: true,
189
+ })
175
190
  expect(result).toContain('export namespace Errors {')
176
- expect(result).toContain('export type ProcedureError =')
177
- expect(result).toContain('export type ProcedureValidationError =')
178
- })
179
-
180
- it('places union inside the namespace', async () => {
181
- const result = await emitErrorsFile([procedureErrorDoc, validationErrorDoc], { namespaceTypes: true })
182
- expect(result).toBeDefined()
183
- expect(result).toContain('export type ProcedureErrorUnion = ProcedureError | ProcedureValidationError')
184
- // Union should be inside the namespace (indented)
185
- const lines = result!.split('\n')
186
- const unionLine = lines.find((l) => l.includes('ProcedureErrorUnion'))
187
- expect(unionLine).toMatch(/^\s+export type ProcedureErrorUnion/)
191
+ expect(result).toContain('export class ProcedureError')
192
+ expect(result).toContain('export const ErrorRegistry = {')
188
193
  })
189
194
 
190
- it('includes JSDoc inside namespace', async () => {
195
+ it('contents are indented inside the namespace block', async () => {
191
196
  const result = await emitErrorsFile([procedureErrorDoc], { namespaceTypes: true })
192
- expect(result).toBeDefined()
193
- expect(result).toContain('(HTTP 500)')
197
+ const classLine = result!.split('\n').find((l) => l.includes('export class ProcedureError'))
198
+ expect(classLine).toMatch(/^\s{2}export class ProcedureError/)
194
199
  })
195
200
 
196
201
  it('flat mode does not wrap in namespace', async () => {
197
202
  const result = await emitErrorsFile([procedureErrorDoc], { namespaceTypes: false })
198
- expect(result).toBeDefined()
199
- expect(result).not.toContain('export namespace Errors')
203
+ expect(result).not.toContain('export namespace')
200
204
  })
201
205
  })
202
206
  })
@@ -1,4 +1,4 @@
1
- import { jsonSchemaToTypeString, jsonSchemaToExtractedTypes, type AjscOptions } from './emit-types.js'
1
+ import { jsonSchemaToTypeBody, type AjscOptions } from './emit-types.js'
2
2
  import type { ErrorDoc } from '../implementations/types.js'
3
3
  import { CODEGEN_HEADER } from './constants.js'
4
4
  import { toPascalCase } from './naming.js'
@@ -11,14 +11,25 @@ export interface EmitErrorsOptions {
11
11
  }
12
12
 
13
13
  /**
14
- * Generates a TypeScript file with error type declarations from the DocEnvelope.errors array.
14
+ * Generates a TypeScript file with runtime error classes (plus a registry
15
+ * object for dispatch) derived from `DocEnvelope.errors`.
15
16
  *
16
- * Only errors with a `schema` property are included. If no errors have schemas,
17
- * returns `undefined` (no file generated).
17
+ * For each error with a schema, emits:
18
+ * - `interface <Name>Body { ... }` typed response body
19
+ * - `class <Name> extends <ServiceName>ProcedureError<<Name>Body>` with
20
+ * constructor + static `fromResponse(body, meta)`
18
21
  *
19
- * When `namespaceTypes` is true, wraps in `export namespace Errors { ... }`
20
- * (or `${ServiceName}Errors` with `serviceName`). The union type is similarly
21
- * prefixed: `ProcedureErrorUnion` `${ServiceName}ProcedureErrorUnion`.
22
+ * The shared base `<ServiceName>ProcedureError` is emitted so consumers can
23
+ * `catch (e) { if (e instanceof ApiErrors.ApiProcedureError) ... }` to handle
24
+ * any service error in one block. `instanceof` works across bundler boundaries
25
+ * because the generated file holds the sole class definition at runtime.
26
+ *
27
+ * The registry `<ServiceName>ErrorRegistry` maps body `name` values to
28
+ * classes, consumed by the client's `dispatchTypedError` to produce typed
29
+ * errors instead of generic `ClientRequestError` instances.
30
+ *
31
+ * When `namespaceTypes` is on, everything is wrapped in `export namespace
32
+ * <ServiceName>Errors { ... }`. Returns `undefined` if no errors have schemas.
22
33
  */
23
34
  export async function emitErrorsFile(
24
35
  errors: ErrorDoc[],
@@ -27,54 +38,125 @@ export async function emitErrorsFile(
27
38
  const { ajsc: ajscOpts, namespaceTypes = false, serviceName } = options ?? {}
28
39
  const servicePrefix = serviceName ? toPascalCase(serviceName) : ''
29
40
  const namespaceName = servicePrefix ? `${servicePrefix}Errors` : 'Errors'
41
+ const baseClassName = servicePrefix ? `${servicePrefix}ProcedureError` : 'ProcedureErrorBase'
30
42
  const unionName = servicePrefix ? `${servicePrefix}ProcedureErrorUnion` : 'ProcedureErrorUnion'
43
+ const registryName = servicePrefix ? `${servicePrefix}ErrorRegistry` : 'ErrorRegistry'
31
44
 
32
- // Filter to only errors that have a schema
33
- const errorsWithSchema = errors.filter((e): e is ErrorDoc & { schema: Record<string, unknown> } =>
34
- e.schema != null
45
+ const errorsWithSchema = errors.filter(
46
+ (e): e is ErrorDoc & { schema: Record<string, unknown> } => e.schema != null
35
47
  )
36
48
 
37
49
  if (errorsWithSchema.length === 0) {
38
50
  return undefined
39
51
  }
40
52
 
41
- const typeLines: string[] = []
53
+ // Compute the typed body for each error by converting its schema to a TS type.
54
+ const entries: Array<{
55
+ doc: (typeof errorsWithSchema)[number]
56
+ bodyType: string
57
+ }> = []
58
+ for (const doc of errorsWithSchema) {
59
+ const bodyType = await jsonSchemaToTypeBody(doc.schema, ajscOpts)
60
+ entries.push({ doc, bodyType: bodyType ?? 'unknown' })
61
+ }
62
+
63
+ const lines: string[] = []
64
+ const indent = namespaceTypes ? ' ' : ''
42
65
 
43
66
  if (namespaceTypes) {
44
- typeLines.push(`export namespace ${namespaceName} {`)
67
+ lines.push(`export namespace ${namespaceName} {`)
68
+ }
69
+
70
+ // Shared base class — lets consumers catch any service error with one check.
71
+ lines.push(
72
+ `${indent}/** Base class for every generated error in this service. Catch with \`instanceof\`. */`,
73
+ `${indent}export class ${baseClassName}<TBody = unknown> extends Error {`,
74
+ `${indent} readonly status: number`,
75
+ `${indent} readonly procedureName: string`,
76
+ `${indent} readonly scope: string`,
77
+ `${indent} readonly body: TBody`,
78
+ `${indent} constructor(args: {`,
79
+ `${indent} name: string`,
80
+ `${indent} message: string`,
81
+ `${indent} status: number`,
82
+ `${indent} procedureName: string`,
83
+ `${indent} scope: string`,
84
+ `${indent} body: TBody`,
85
+ `${indent} }) {`,
86
+ `${indent} super(args.message)`,
87
+ `${indent} this.name = args.name`,
88
+ `${indent} this.status = args.status`,
89
+ `${indent} this.procedureName = args.procedureName`,
90
+ `${indent} this.scope = args.scope`,
91
+ `${indent} this.body = args.body`,
92
+ `${indent} Object.setPrototypeOf(this, new.target.prototype)`,
93
+ `${indent} }`,
94
+ `${indent}}`,
95
+ ''
96
+ )
45
97
 
46
- for (const error of errorsWithSchema) {
47
- const result = await jsonSchemaToExtractedTypes(error.schema, ajscOpts)
48
- if (result != null) {
49
- typeLines.push(` /** ${error.description} (HTTP ${error.statusCode}) */`)
50
- for (const decl of result.declarations) {
51
- typeLines.push(` ${decl}`)
52
- }
53
- typeLines.push(` export type ${error.name} = ${result.body}`)
54
- typeLines.push('')
55
- }
56
- }
98
+ // Per-error body interface + class with fromResponse static factory.
99
+ for (const { doc, bodyType } of entries) {
100
+ const bodyInterfaceName = `${doc.name}Body`
101
+ lines.push(
102
+ `${indent}/** Response body for ${doc.name}. */`,
103
+ `${indent}export type ${bodyInterfaceName} = ${bodyType}`,
104
+ ''
105
+ )
57
106
 
58
- const unionMembers = errorsWithSchema.map((e) => e.name).join(' | ')
59
- typeLines.push(` export type ${unionName} = ${unionMembers}`)
60
- typeLines.push('}')
61
- typeLines.push('')
62
- } else {
63
- for (const error of errorsWithSchema) {
64
- const typeDecl = await jsonSchemaToTypeString(error.name, error.schema, ajscOpts)
65
- if (typeDecl != null) {
66
- typeLines.push(`/** ${error.description} (HTTP ${error.statusCode}) */`)
67
- typeLines.push(typeDecl)
68
- typeLines.push('')
69
- }
70
- }
107
+ const statusLiteral = doc.statusCode
108
+ const descLine = doc.description
109
+ ? `${indent}/** ${doc.description} (HTTP ${statusLiteral}) */`
110
+ : `${indent}/** HTTP ${statusLiteral} */`
71
111
 
72
- const unionMembers = errorsWithSchema.map((e) => e.name).join(' | ')
73
- typeLines.push(`export type ${unionName} = ${unionMembers}`)
74
- typeLines.push('')
112
+ lines.push(
113
+ descLine,
114
+ `${indent}export class ${doc.name} extends ${baseClassName}<${bodyInterfaceName}> {`,
115
+ `${indent} static readonly errorName = '${doc.name}' as const`,
116
+ `${indent} static readonly statusCode = ${statusLiteral}`,
117
+ `${indent} static fromResponse(`,
118
+ `${indent} body: ${bodyInterfaceName},`,
119
+ `${indent} meta: { status: number; procedureName: string; scope: string }`,
120
+ `${indent} ): ${doc.name} {`,
121
+ `${indent} const message =`,
122
+ `${indent} body && typeof (body as { message?: unknown }).message === 'string'`,
123
+ `${indent} ? (body as { message: string }).message`,
124
+ `${indent} : '${doc.name}'`,
125
+ `${indent} return new ${doc.name}({`,
126
+ `${indent} name: '${doc.name}',`,
127
+ `${indent} message,`,
128
+ `${indent} status: meta.status,`,
129
+ `${indent} procedureName: meta.procedureName,`,
130
+ `${indent} scope: meta.scope,`,
131
+ `${indent} body,`,
132
+ `${indent} })`,
133
+ `${indent} }`,
134
+ `${indent}}`,
135
+ ''
136
+ )
75
137
  }
76
138
 
77
- const body = typeLines.join('\n')
139
+ // Union type — every generated error class instance.
140
+ const unionMembers = entries.map((e) => e.doc.name).join(' | ')
141
+ lines.push(
142
+ `${indent}/** Union of every generated error in this service. */`,
143
+ `${indent}export type ${unionName} = ${unionMembers}`,
144
+ ''
145
+ )
146
+
147
+ // Registry for runtime dispatch — keyed by body.name.
148
+ lines.push(
149
+ `${indent}/** Runtime registry consumed by the client to dispatch by \`body.name\`. */`,
150
+ `${indent}export const ${registryName} = {`,
151
+ ...entries.map((e) => `${indent} ${e.doc.name},`),
152
+ `${indent}} as const`,
153
+ ''
154
+ )
155
+
156
+ if (namespaceTypes) {
157
+ lines.push('}')
158
+ lines.push('')
159
+ }
78
160
 
79
- return [CODEGEN_HEADER, '', body].join('\n')
161
+ return [CODEGEN_HEADER, '', lines.join('\n')].join('\n')
80
162
  }