ts-procedures 6.2.0 → 7.0.0-beta.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 (113) hide show
  1. package/README.md +2 -0
  2. package/agent_config/claude-code/skills/ts-procedures/SKILL.md +2 -1
  3. package/agent_config/claude-code/skills/ts-procedures/anti-patterns.md +38 -1
  4. package/agent_config/claude-code/skills/ts-procedures/api-reference.md +253 -3
  5. package/agent_config/claude-code/skills/ts-procedures/patterns.md +60 -2
  6. package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/client.md +1 -1
  7. package/agent_config/copilot/copilot-instructions.md +3 -0
  8. package/agent_config/cursor/cursorrules +3 -0
  9. package/build/client/augment-error-map.test-d.d.ts +10 -0
  10. package/build/client/augment-error-map.test-d.js +14 -0
  11. package/build/client/augment-error-map.test-d.js.map +1 -0
  12. package/build/client/bind-callable.test.d.ts +1 -0
  13. package/build/client/bind-callable.test.js +132 -0
  14. package/build/client/bind-callable.test.js.map +1 -0
  15. package/build/client/call.d.ts +14 -2
  16. package/build/client/call.js +96 -9
  17. package/build/client/call.js.map +1 -1
  18. package/build/client/call.test.js +50 -1
  19. package/build/client/call.test.js.map +1 -1
  20. package/build/client/classify-error.d.ts +11 -0
  21. package/build/client/classify-error.js +49 -0
  22. package/build/client/classify-error.js.map +1 -0
  23. package/build/client/classify-error.test.d.ts +1 -0
  24. package/build/client/classify-error.test.js +55 -0
  25. package/build/client/classify-error.test.js.map +1 -0
  26. package/build/client/error-dispatch.d.ts +1 -1
  27. package/build/client/error-dispatch.js +1 -1
  28. package/build/client/errors.d.ts +55 -4
  29. package/build/client/errors.js +54 -7
  30. package/build/client/errors.js.map +1 -1
  31. package/build/client/errors.test.js +89 -4
  32. package/build/client/errors.test.js.map +1 -1
  33. package/build/client/fetch-adapter.d.ts +2 -1
  34. package/build/client/fetch-adapter.js +2 -1
  35. package/build/client/fetch-adapter.js.map +1 -1
  36. package/build/client/fetch-adapter.test.js +12 -0
  37. package/build/client/fetch-adapter.test.js.map +1 -1
  38. package/build/client/index.d.ts +5 -3
  39. package/build/client/index.js +29 -3
  40. package/build/client/index.js.map +1 -1
  41. package/build/client/resolve-options.d.ts +32 -1
  42. package/build/client/resolve-options.js +32 -16
  43. package/build/client/resolve-options.js.map +1 -1
  44. package/build/client/resolve-options.test.js +67 -6
  45. package/build/client/resolve-options.test.js.map +1 -1
  46. package/build/client/result-type.test-d.d.ts +1 -0
  47. package/build/client/result-type.test-d.js +28 -0
  48. package/build/client/result-type.test-d.js.map +1 -0
  49. package/build/client/safe-call.test.d.ts +1 -0
  50. package/build/client/safe-call.test.js +137 -0
  51. package/build/client/safe-call.test.js.map +1 -0
  52. package/build/client/stream.d.ts +1 -1
  53. package/build/client/stream.js +22 -8
  54. package/build/client/stream.js.map +1 -1
  55. package/build/client/stream.test.js +11 -1
  56. package/build/client/stream.test.js.map +1 -1
  57. package/build/client/types.d.ts +117 -3
  58. package/build/codegen/bundle-size.test.d.ts +1 -0
  59. package/build/codegen/bundle-size.test.js +70 -0
  60. package/build/codegen/bundle-size.test.js.map +1 -0
  61. package/build/codegen/e2e.test.js +108 -7
  62. package/build/codegen/e2e.test.js.map +1 -1
  63. package/build/codegen/emit-client-runtime.js +8 -0
  64. package/build/codegen/emit-client-runtime.js.map +1 -1
  65. package/build/codegen/emit-client-runtime.test.js +6 -2
  66. package/build/codegen/emit-client-runtime.test.js.map +1 -1
  67. package/build/codegen/emit-client-types.d.ts +7 -2
  68. package/build/codegen/emit-client-types.js +29 -8
  69. package/build/codegen/emit-client-types.js.map +1 -1
  70. package/build/codegen/emit-client-types.test.js +20 -8
  71. package/build/codegen/emit-client-types.test.js.map +1 -1
  72. package/build/codegen/emit-errors.d.ts +1 -1
  73. package/build/codegen/emit-errors.js +1 -1
  74. package/build/codegen/emit-index.js +1 -1
  75. package/build/codegen/emit-index.js.map +1 -1
  76. package/build/codegen/emit-scope.js +37 -25
  77. package/build/codegen/emit-scope.js.map +1 -1
  78. package/build/codegen/emit-scope.test.js +310 -14
  79. package/build/codegen/emit-scope.test.js.map +1 -1
  80. package/docs/client-and-codegen.md +77 -7
  81. package/docs/client-error-handling.md +357 -0
  82. package/docs/superpowers/plans/2026-04-29-safe-result-api.md +2293 -0
  83. package/docs/superpowers/specs/2026-04-29-safe-result-api-design.md +324 -0
  84. package/package.json +1 -1
  85. package/src/client/augment-error-map.test-d.ts +22 -0
  86. package/src/client/bind-callable.test.ts +137 -0
  87. package/src/client/call.test.ts +65 -1
  88. package/src/client/call.ts +111 -9
  89. package/src/client/classify-error.test.ts +65 -0
  90. package/src/client/classify-error.ts +59 -0
  91. package/src/client/error-dispatch.ts +1 -1
  92. package/src/client/errors.test.ts +108 -4
  93. package/src/client/errors.ts +70 -7
  94. package/src/client/fetch-adapter.test.ts +15 -0
  95. package/src/client/fetch-adapter.ts +5 -2
  96. package/src/client/index.ts +60 -3
  97. package/src/client/resolve-options.test.ts +83 -5
  98. package/src/client/resolve-options.ts +61 -16
  99. package/src/client/result-type.test-d.ts +51 -0
  100. package/src/client/safe-call.test.ts +157 -0
  101. package/src/client/stream.test.ts +13 -1
  102. package/src/client/stream.ts +25 -8
  103. package/src/client/types.ts +137 -3
  104. package/src/codegen/bundle-size.test.ts +76 -0
  105. package/src/codegen/e2e.test.ts +113 -7
  106. package/src/codegen/emit-client-runtime.test.ts +7 -2
  107. package/src/codegen/emit-client-runtime.ts +8 -0
  108. package/src/codegen/emit-client-types.test.ts +22 -7
  109. package/src/codegen/emit-client-types.ts +35 -10
  110. package/src/codegen/emit-errors.ts +1 -1
  111. package/src/codegen/emit-index.ts +1 -1
  112. package/src/codegen/emit-scope.test.ts +337 -14
  113. package/src/codegen/emit-scope.ts +39 -35
@@ -30,11 +30,52 @@
30
30
  // eslint-disable-next-line @typescript-eslint/no-empty-object-type
31
31
  export interface RequestMeta {}
32
32
 
33
+ // ── Error Classifier ─────────────────────────────────────
34
+
35
+ /**
36
+ * Classification context — supplies provenance the classifier needs that's
37
+ * not derivable from the raw error itself.
38
+ *
39
+ * `timeoutSignal` and `userSignal` let the classifier distinguish a timeout
40
+ * abort from a user-initiated abort when an `AbortError` lands.
41
+ */
42
+ export interface ClassifyErrorContext {
43
+ procedureName: string
44
+ scope: string
45
+ timeoutSignal?: AbortSignal
46
+ userSignal?: AbortSignal
47
+ timeoutMs?: number
48
+ }
49
+
50
+ /**
51
+ * The output shape of a successful classification. Contract: `error` is
52
+ * always an `Error` subclass (the framework class). Non-`Error` values fall
53
+ * through to `null` (handled by `executeCall` as `kind: 'unknown'`).
54
+ */
55
+ export interface ClassifiedError {
56
+ kind: string
57
+ error: Error
58
+ }
59
+
60
+ /**
61
+ * Adapter-provided classifier — runs before `defaultClassifyError`. Return
62
+ * `null` to fall through to the default. Adapter authors should compose with
63
+ * the default explicitly:
64
+ *
65
+ * classifyError: (e, ctx) => myClassify(e, ctx) ?? defaultClassifyError(e, ctx)
66
+ */
67
+ export type ErrorClassifier = (
68
+ raw: unknown,
69
+ ctx: ClassifyErrorContext,
70
+ ) => ClassifiedError | null
71
+
33
72
  // ── Adapter ──────────────────────────────────────────────
34
73
 
35
74
  export interface ClientAdapter {
36
75
  request(config: AdapterRequest): Promise<AdapterResponse>
37
76
  stream(config: AdapterRequest): Promise<AdapterStreamResponse>
77
+ /** Optional adapter-level error classifier — composes with `defaultClassifyError`. */
78
+ classifyError?: ErrorClassifier
38
79
  }
39
80
 
40
81
  export interface AdapterRequest {
@@ -63,7 +104,7 @@ export interface AdapterStreamResponse {
63
104
  /**
64
105
  * Populated when `status` is non-2xx — the parsed response body. Surfaced so
65
106
  * `executeStream` can dispatch typed errors via the error registry instead
66
- * of always falling back to `ClientRequestError` with `body: null`.
107
+ * of always falling back to `ClientHttpError` with `body: null`.
67
108
  */
68
109
  errorBody?: unknown
69
110
  }
@@ -177,7 +218,7 @@ export interface ErrorFactory {
177
218
  /**
178
219
  * Maps `body.name` values (taxonomy keys) to error class factories. When the
179
220
  * 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`.
221
+ * entry, it throws the typed error instead of a generic `ClientHttpError`.
181
222
  */
182
223
  export type ErrorRegistry = Record<string, ErrorFactory>
183
224
 
@@ -191,7 +232,36 @@ export interface ClientInstance {
191
232
  /** Optional registry for runtime dispatch of typed errors by `body.name`. */
192
233
  errorRegistry?: ErrorRegistry
193
234
  call<TResponse>(descriptor: CallDescriptor, options?: ProcedureCallOptions): Promise<TResponse>
235
+ safeCall<TResponse, ETyped = never>(
236
+ descriptor: CallDescriptor,
237
+ options?: ProcedureCallOptions,
238
+ ): Promise<Result<TResponse, ETyped>>
194
239
  stream<TYield, TReturn>(descriptor: StreamDescriptor, options?: ProcedureCallOptions): TypedStream<TYield, TReturn>
240
+ /**
241
+ * Wires a callable with a `.safe` sibling for routes without declared typed errors.
242
+ * Used by codegen to produce a compact one-line callable per route.
243
+ *
244
+ * The returned function's `.name` is set to `descriptor.name` for nicer stack traces.
245
+ */
246
+ bindCallable<TParams, TResponse>(
247
+ descriptor: Omit<CallDescriptor, 'params'>,
248
+ ): {
249
+ (params: TParams, options?: ProcedureCallOptions): Promise<TResponse>
250
+ safe(params: TParams, options?: ProcedureCallOptions): Promise<ResultNoTyped<TResponse>>
251
+ }
252
+ /**
253
+ * Wires a callable with a `.safe` sibling for routes with declared typed errors.
254
+ * The `.safe` form returns `Result<TResponse, ETyped>` where `ETyped` is the
255
+ * union of route-declared error classes.
256
+ *
257
+ * The returned function's `.name` is set to `descriptor.name` for nicer stack traces.
258
+ */
259
+ bindCallableTyped<TParams, TResponse, ETyped>(
260
+ descriptor: Omit<CallDescriptor, 'params'>,
261
+ ): {
262
+ (params: TParams, options?: ProcedureCallOptions): Promise<TResponse>
263
+ safe(params: TParams, options?: ProcedureCallOptions): Promise<Result<TResponse, ETyped>>
264
+ }
195
265
  }
196
266
 
197
267
  // ── createClient Config ──────────────────────────────────
@@ -211,7 +281,71 @@ export interface CreateClientConfig<TScopes> {
211
281
  * Optional error-dispatch registry. When a non-2xx response body has a
212
282
  * `name` field matching a registry key, the client throws the typed error
213
283
  * constructed via that entry's `fromResponse`. When absent or when no key
214
- * matches, falls back to `ClientRequestError` (transport error shape).
284
+ * matches, falls back to `ClientHttpError` (transport error shape).
215
285
  */
216
286
  errorRegistry?: ErrorRegistry
217
287
  }
288
+
289
+ // ── Result Types ─────────────────────────────────────────
290
+
291
+ import type {
292
+ ClientHttpError,
293
+ ClientNetworkError,
294
+ ClientTimeoutError,
295
+ ClientAbortError,
296
+ ClientParseError,
297
+ ClientPathParamError,
298
+ } from './errors.js'
299
+
300
+ /**
301
+ * Augmentable map of `kind` discriminant → error class for the framework's
302
+ * non-typed failure categories. Mirrors the `RequestMeta` augmentation
303
+ * pattern: extend via TypeScript declaration merging.
304
+ *
305
+ * @example
306
+ * ```ts
307
+ * declare module 'ts-procedures/client' {
308
+ * interface ClientErrorMap {
309
+ * rateLimited: MyRateLimitError
310
+ * paymentRequired: MyPaymentError
311
+ * }
312
+ * }
313
+ * ```
314
+ *
315
+ * **`'typed'` is reserved** — it's the discriminant used by `Result` for
316
+ * route-declared errors and is not part of `ClientErrorMap`. Attempting to
317
+ * register it produces a TS error about overlapping discriminants.
318
+ */
319
+ export interface ClientErrorMap {
320
+ http: ClientHttpError
321
+ network: ClientNetworkError
322
+ timeout: ClientTimeoutError
323
+ aborted: ClientAbortError
324
+ parse: ClientParseError
325
+ usage: ClientPathParamError
326
+ unknown: unknown
327
+ }
328
+
329
+ /** Distributed union of every framework failure kind. */
330
+ export type FrameworkFailure = {
331
+ [K in keyof ClientErrorMap]: { ok: false; kind: K; error: ClientErrorMap[K] }
332
+ }[keyof ClientErrorMap]
333
+
334
+ /**
335
+ * Discriminated result type for the `.safe()` form. `kind: 'typed'` carries
336
+ * route-declared errors (registry-dispatched); other kinds carry framework
337
+ * failures. Use `ResultNoTyped<T>` for routes without declared errors.
338
+ */
339
+ export type Result<T, ETyped> =
340
+ | { ok: true; value: T }
341
+ | { ok: false; kind: 'typed'; error: ETyped }
342
+ | FrameworkFailure
343
+
344
+ /**
345
+ * `Result` for routes that don't declare typed errors. Omits the `'typed'`
346
+ * arm entirely so IDE hovers stay clean (TS doesn't collapse `never`-payload
347
+ * arms in tooltip output).
348
+ */
349
+ export type ResultNoTyped<T> =
350
+ | { ok: true; value: T }
351
+ | FrameworkFailure
@@ -0,0 +1,76 @@
1
+ import { describe, it, expect, beforeAll } from 'vitest'
2
+ import { generateClient } from './index.js'
3
+ import * as path from 'node:path'
4
+ import * as os from 'node:os'
5
+ import * as fs from 'node:fs/promises'
6
+ import type { DocEnvelope } from '../implementations/types.js'
7
+
8
+ function makeEnvelope(routeCount: number): DocEnvelope {
9
+ const routes = Array.from({ length: routeCount }, (_, i) => ({
10
+ kind: 'rpc' as const,
11
+ name: `Op${i}`,
12
+ scope: 'ops',
13
+ method: 'post' as const,
14
+ path: `/ops/op${i}`,
15
+ version: 1,
16
+ jsonSchema: {
17
+ body: { type: 'object' as const, properties: { x: { type: 'string' as const } } },
18
+ response: { type: 'object' as const, properties: { y: { type: 'string' as const } } },
19
+ },
20
+ errors: [] as string[],
21
+ }))
22
+ return { basePath: '/api', headers: [], errors: [], routes }
23
+ }
24
+
25
+ describe('bundle size budget', () => {
26
+ let perRouteDelta: number
27
+ let totalChars: number
28
+
29
+ beforeAll(async () => {
30
+ const tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'tsproc-bundle-'))
31
+ await generateClient({
32
+ envelope: makeEnvelope(100),
33
+ outDir: tmp,
34
+ selfContained: false,
35
+ })
36
+ const scopeFile = await fs.readFile(path.join(tmp, 'ops.ts'), 'utf-8')
37
+
38
+ // Strip whitespace, line comments, block comments. This is a stable proxy
39
+ // for minification — actual minified bytes will be lower, but the *delta*
40
+ // between commits is what this test guards against.
41
+ const stripped = scopeFile
42
+ .replace(/\/\*[\s\S]*?\*\//g, '') // block comments
43
+ .replace(/\/\/.*$/gm, '') // line comments
44
+ .replace(/\s+/g, ' ') // collapse whitespace
45
+ totalChars = stripped.length
46
+ perRouteDelta = totalChars / 100
47
+ })
48
+
49
+ it('stays within 1500 chars per route post-strip', () => {
50
+ // Initial budget — refine after first measurement. The .safe sibling adds
51
+ // roughly 400 chars of unminified emission per route (Object.assign wrapper
52
+ // + duplicated descriptor literal). Post-strip should land well under 1500.
53
+ //
54
+ // RELATIONSHIP TO SPEC TARGET: The spec sets a "200 bytes per route
55
+ // post-minify" goal. This whitespace-strip proxy is a coarse stand-in
56
+ // chosen to avoid an esbuild dep — actual minified bytes will be lower
57
+ // than `perRouteDelta` here. If a future task swaps in real esbuild
58
+ // minification, tighten the budget to ~250 bytes (200 + 25% headroom)
59
+ // and link back to spec section "Tests / item 10". For now this guard
60
+ // catches catastrophic regressions only, not subtle bloat.
61
+ //
62
+ // PERROUTEDELTA_BASELINE: 613.2 (7.0.0-beta.0, Object.assign inline emission)
63
+ // After bindCallable refactor (beta.1): baseline dropped to 218.8 (~64% reduction)
64
+ // — Object.assign + duplicated descriptor removed; one helper call per route.
65
+ expect(perRouteDelta).toBeLessThan(1500)
66
+ })
67
+
68
+ it('logs the baseline measurement for review', () => {
69
+ // This isn't a strict assertion — it surfaces the actual numbers to the
70
+ // test output so reviewers can update PERROUTEDELTA_BASELINE in the
71
+ // comment above without having to re-run locally.
72
+ // eslint-disable-next-line no-console
73
+ console.log(`[bundle-size] 100 routes, scope file = ${totalChars} chars stripped, per-route = ${perRouteDelta.toFixed(1)}`)
74
+ expect(totalChars).toBeGreaterThan(0)
75
+ })
76
+ })
@@ -223,12 +223,12 @@ describe('E2E: generateClient full pipeline', () => {
223
223
  expect(content).toContain('bindUsersScope')
224
224
  })
225
225
 
226
- it('users.ts uses client.call for RPC route', async () => {
226
+ it('users.ts uses client.bindCallable for RPC route', async () => {
227
227
  tmpDir = makeTmpDir()
228
228
  await generateClient({ envelope, outDir: tmpDir })
229
229
 
230
230
  const content = readFileSync(join(tmpDir, 'users.ts'), 'utf-8')
231
- expect(content).toContain('client.call')
231
+ expect(content).toContain('client.bindCallable<')
232
232
  })
233
233
 
234
234
  // ── events.ts — Stream route ───────────────────────────────────────────────
@@ -422,7 +422,7 @@ describe('E2E: generateClient full pipeline', () => {
422
422
  expect(content).toContain('ProcedureCallOptions')
423
423
  })
424
424
 
425
- it('_client.ts is generated and contains createClient, createFetchAdapter, ClientRequestError', async () => {
425
+ it('_client.ts is generated and contains createClient, createFetchAdapter, ClientHttpError (+ deprecated ClientRequestError alias)', async () => {
426
426
  tmpDir = makeTmpDir()
427
427
  await generateClient({ envelope, outDir: tmpDir, selfContained: true })
428
428
 
@@ -430,6 +430,7 @@ describe('E2E: generateClient full pipeline', () => {
430
430
  const content = readFileSync(join(tmpDir, '_client.ts'), 'utf-8')
431
431
  expect(content).toContain('createClient')
432
432
  expect(content).toContain('createFetchAdapter')
433
+ expect(content).toContain('ClientHttpError')
433
434
  expect(content).toContain('ClientRequestError')
434
435
  })
435
436
 
@@ -584,6 +585,112 @@ void run
584
585
  }).not.toThrow()
585
586
  })
586
587
 
588
+ it('generated .safe callable type-checks against bundled Result/ResultNoTyped types', async () => {
589
+ // Use an envelope with two routes:
590
+ // - GetUser: has errors: ['ProcedureError'] → .safe returns Result<T, GetUserErrors>
591
+ // - ListUsers: has errors: [] → .safe returns ResultNoTyped<T>
592
+ // This verifies both the typed-error and no-typed-error branches of .safe.
593
+ tmpDir = makeTmpDir()
594
+ const safeEnvelope: DocEnvelope = {
595
+ basePath: '/api',
596
+ headers: [],
597
+ errors: [procedureErrorDoc],
598
+ routes: [
599
+ {
600
+ kind: 'rpc',
601
+ name: 'GetUser',
602
+ path: '/users/1',
603
+ method: 'post',
604
+ scope: 'users',
605
+ version: 1,
606
+ errors: ['ProcedureError'],
607
+ jsonSchema: {
608
+ body: {
609
+ type: 'object',
610
+ properties: { id: { type: 'string' } },
611
+ required: ['id'],
612
+ },
613
+ response: {
614
+ type: 'object',
615
+ properties: { name: { type: 'string' } },
616
+ required: ['name'],
617
+ },
618
+ },
619
+ } as RPCHttpRouteDoc,
620
+ {
621
+ kind: 'rpc',
622
+ name: 'ListUsers',
623
+ path: '/users',
624
+ method: 'post',
625
+ scope: 'users',
626
+ version: 1,
627
+ errors: [],
628
+ jsonSchema: {
629
+ body: { type: 'object' },
630
+ response: {
631
+ type: 'array',
632
+ items: {
633
+ type: 'object',
634
+ properties: { name: { type: 'string' } },
635
+ required: ['name'],
636
+ },
637
+ },
638
+ },
639
+ } as RPCHttpRouteDoc,
640
+ ],
641
+ }
642
+
643
+ await generateClient({ envelope: safeEnvelope, outDir: tmpDir, selfContained: true })
644
+
645
+ // Consumer exercises .safe on both routes and verifies the Result types
646
+ // are assignable (type-only assertions via declared variables).
647
+ const consumer = `
648
+ import type { Result, ResultNoTyped } from './_types'
649
+ import { createApiClient } from './index'
650
+ import { createFetchAdapter } from './_client'
651
+
652
+ const client = createApiClient({
653
+ adapter: createFetchAdapter(),
654
+ basePath: 'https://api.example.com',
655
+ })
656
+
657
+ // Throwing form still compiles
658
+ const _p1: Promise<{ name: string }> = client.users.GetUser({ id: '1' })
659
+ void _p1
660
+
661
+ // .safe on a route with declared errors returns Result<T, Errors>
662
+ const _p2 = client.users.GetUser.safe({ id: '1' })
663
+ const _p2check: Promise<Result<{ name: string }, unknown>> = _p2 as Promise<Result<{ name: string }, unknown>>
664
+ void _p2check
665
+
666
+ // .safe on a route without declared errors returns ResultNoTyped<T>
667
+ const _p3 = client.users.ListUsers.safe({})
668
+ const _p3check: Promise<ResultNoTyped<unknown>> = _p3 as Promise<ResultNoTyped<unknown>>
669
+ void _p3check
670
+ `
671
+ const { writeFileSync } = await import('node:fs')
672
+ writeFileSync(join(tmpDir, 'consumer.ts'), consumer)
673
+
674
+ const tsconfig = {
675
+ compilerOptions: {
676
+ strict: true,
677
+ target: 'ES2022',
678
+ module: 'ES2022',
679
+ moduleResolution: 'bundler',
680
+ noEmit: true,
681
+ skipLibCheck: true,
682
+ },
683
+ include: ['_types.ts', '_client.ts', 'index.ts', 'users.ts', '_errors.ts', 'consumer.ts'],
684
+ }
685
+ writeFileSync(join(tmpDir, 'tsconfig.json'), JSON.stringify(tsconfig))
686
+
687
+ const { execSync } = await import('node:child_process')
688
+ const tscPath = join(process.cwd(), 'node_modules', '.bin', 'tsc')
689
+ expect(() => {
690
+ execSync(`${tscPath} --noEmit --project ${join(tmpDir, 'tsconfig.json')}`, { stdio: 'pipe' })
691
+ }).not.toThrow()
692
+ })
693
+
587
694
  it('augmented RequestMeta rejects wrong types (compile error)', async () => {
588
695
  tmpDir = makeTmpDir()
589
696
  await generateClient({ envelope, outDir: tmpDir, selfContained: true })
@@ -657,10 +764,9 @@ void run
657
764
  await generateClient({ envelope, outDir: tmpDir, namespaceTypes: true })
658
765
 
659
766
  const content = readFileSync(join(tmpDir, 'users.ts'), 'utf-8')
660
- expect(content).toContain('params: Users.GetUser.Params')
661
- expect(content).toContain('Promise<Users.GetUser.Response>')
662
- expect(content).toContain('params: Users.UpdateUser.Params')
663
- expect(content).toContain('Promise<Users.UpdateUser.Response>')
767
+ // New emission: client.bindCallable<Users.GetUser.Params, Users.GetUser.Response>({...})
768
+ expect(content).toContain('client.bindCallable<Users.GetUser.Params, Users.GetUser.Response>')
769
+ expect(content).toContain('client.bindCallable<Users.UpdateUser.Params, Users.UpdateUser.Response>')
664
770
  })
665
771
 
666
772
  it('events.ts wraps stream types in namespace', async () => {
@@ -46,9 +46,14 @@ describe('emitClientRuntimeFile', () => {
46
46
  expect(result).toMatch(/export function createFetchAdapter/)
47
47
  })
48
48
 
49
- it('contains export class ClientRequestError', async () => {
49
+ it('contains export class ClientHttpError (renamed from ClientRequestError)', async () => {
50
50
  const result = await emitClientRuntimeFile()
51
- expect(result).toMatch(/export class ClientRequestError/)
51
+ expect(result).toMatch(/export class ClientHttpError/)
52
+ })
53
+
54
+ it('contains deprecated ClientRequestError alias', async () => {
55
+ const result = await emitClientRuntimeFile()
56
+ expect(result).toContain('ClientRequestError')
52
57
  })
53
58
 
54
59
  it('contains export class ClientPathParamError', async () => {
@@ -23,6 +23,13 @@ const TYPES_IMPORT = `import type {
23
23
  ErrorRegistry,
24
24
  ErrorFactory,
25
25
  ErrorResponseMeta,
26
+ ErrorClassifier,
27
+ ClassifyErrorContext,
28
+ ClassifiedError,
29
+ Result,
30
+ ResultNoTyped,
31
+ ClientErrorMap,
32
+ FrameworkFailure,
26
33
  } from './_types'`
27
34
 
28
35
  /**
@@ -31,6 +38,7 @@ const TYPES_IMPORT = `import type {
31
38
  */
32
39
  const SOURCE_FILES = [
33
40
  'errors.ts',
41
+ 'classify-error.ts',
34
42
  'error-dispatch.ts',
35
43
  'request-builder.ts',
36
44
  'resolve-options.ts',
@@ -19,21 +19,36 @@ describe('emitClientTypesFile', () => {
19
19
 
20
20
  it('no import statements', async () => {
21
21
  const result = await emitClientTypesFile()
22
- // types.ts has zero imports — the emitted file should be fully self-contained
22
+ // errors.ts and types.ts imports are stripped — the emitted file should be fully self-contained
23
23
  expect(result).not.toMatch(/^import\s/m)
24
24
  })
25
25
 
26
- it('output content (after header) matches src/client/types.ts verbatim', async () => {
26
+ it('includes errors.ts content before types.ts content', async () => {
27
27
  const result = await emitClientTypesFile()
28
28
 
29
29
  const __filename = fileURLToPath(import.meta.url)
30
30
  const __dirname = dirname(__filename)
31
31
  const packageRoot = resolve(__dirname, '../..')
32
- const typesPath = resolve(packageRoot, 'src/client/types.ts')
33
- const typesContent = await readFile(typesPath, 'utf-8')
34
32
 
35
- // The function prepends CODEGEN_HEADER + '\n' + '\n' before the file content
36
- const expected = [CODEGEN_HEADER, '', typesContent].join('\n')
37
- expect(result).toBe(expected)
33
+ // Check that both files' unique identifiers are present
34
+ // errors.ts contains class definitions
35
+ expect(result).toContain('class ClientHttpError')
36
+ expect(result).toContain('class ClientNetworkError')
37
+ // types.ts contains interfaces
38
+ expect(result).toContain('interface RequestMeta')
39
+ expect(result).toContain('interface ClientAdapter')
40
+
41
+ // errors.ts content should appear before types.ts content
42
+ const errorsIdx = result.indexOf('class ClientHttpError')
43
+ const typesIdx = result.indexOf('interface RequestMeta')
44
+ expect(errorsIdx).toBeLessThan(typesIdx)
45
+ })
46
+
47
+ it('includes Result, ResultNoTyped, ClientErrorMap from types.ts', async () => {
48
+ const result = await emitClientTypesFile()
49
+ expect(result).toContain('interface ClientErrorMap')
50
+ expect(result).toContain('type FrameworkFailure')
51
+ expect(result).toContain('type Result<T, ETyped>')
52
+ expect(result).toContain('type ResultNoTyped<T>')
38
53
  })
39
54
  })
@@ -4,8 +4,26 @@ import { readFile, access } from 'node:fs/promises'
4
4
  import { CODEGEN_HEADER } from './constants.js'
5
5
 
6
6
  /**
7
- * Reads `src/client/types.ts` from the package root and returns it as the
8
- * content of a `_types.ts` file, prepended with the auto-generated header.
7
+ * Strips all `import` statements from source content.
8
+ * These are inter-file imports that become unnecessary in a single bundled file.
9
+ *
10
+ * Note: the regex requires a `from` clause, so side-effect imports like
11
+ * `import './polyfill.js'` are NOT stripped. This is intentional — src/client/
12
+ * has no side-effect imports.
13
+ */
14
+ function stripImports(content: string): string {
15
+ // Handle both single-line and multi-line import statements
16
+ return content.replace(/^import\s[\s\S]*?from\s+['"][^'"]+['"]\s*;?\s*$/gm, '')
17
+ }
18
+
19
+ /**
20
+ * Reads `src/client/errors.ts` and `src/client/types.ts` from the package
21
+ * root and returns them bundled as the content of a `_types.ts` file,
22
+ * prepended with the auto-generated header.
23
+ *
24
+ * `errors.ts` is included first (with imports stripped) because `types.ts`
25
+ * imports the error classes from it. Together they form a fully self-contained
26
+ * `_types.ts` with no external dependencies.
9
27
  *
10
28
  * This enables a self-contained codegen mode where consumers don't need
11
29
  * `ts-procedures` as a runtime dependency.
@@ -15,13 +33,20 @@ export async function emitClientTypesFile(): Promise<string> {
15
33
  const __dirname = dirname(__filename)
16
34
  // Works from both src/codegen/ (source) and build/codegen/ (compiled)
17
35
  const packageRoot = resolve(__dirname, '../..')
36
+ const errorsPath = resolve(packageRoot, 'src/client/errors.ts')
18
37
  const typesPath = resolve(packageRoot, 'src/client/types.ts')
19
- await access(typesPath).catch(() => {
20
- throw new Error(
21
- `[ts-procedures-codegen] Cannot locate src/client/types.ts at expected path: ${typesPath}. ` +
22
- `Ensure ts-procedures is installed correctly.`
23
- )
24
- })
25
- const content = await readFile(typesPath, 'utf-8')
26
- return [CODEGEN_HEADER, '', content].join('\n')
38
+
39
+ for (const [label, filePath] of [['errors.ts', errorsPath], ['types.ts', typesPath]] as const) {
40
+ await access(filePath).catch(() => {
41
+ throw new Error(
42
+ `[ts-procedures-codegen] Cannot locate src/client/${label} at expected path: ${filePath}. ` +
43
+ `Ensure ts-procedures is installed correctly.`
44
+ )
45
+ })
46
+ }
47
+
48
+ const errorsContent = stripImports(await readFile(errorsPath, 'utf-8')).trim()
49
+ const typesContent = stripImports(await readFile(typesPath, 'utf-8')).trim()
50
+
51
+ return [CODEGEN_HEADER, '', errorsContent, '', typesContent, ''].join('\n')
27
52
  }
@@ -26,7 +26,7 @@ export interface EmitErrorsOptions {
26
26
  *
27
27
  * The registry `<ServiceName>ErrorRegistry` maps body `name` values to
28
28
  * classes, consumed by the client's `dispatchTypedError` to produce typed
29
- * errors instead of generic `ClientRequestError` instances.
29
+ * errors instead of generic `ClientHttpError` instances.
30
30
  *
31
31
  * When `namespaceTypes` is on, everything is wrapped in `export namespace
32
32
  * <ServiceName>Errors { ... }`. Returns `undefined` if no errors have schemas.
@@ -112,7 +112,7 @@ export function emitIndexFile(groups: ScopeGroup[], options?: EmitIndexOptions):
112
112
  ` * Creates a typed client for this service with the generated error`,
113
113
  ` * registry pre-configured. Non-2xx responses whose body \`name\` matches`,
114
114
  ` * a registered error are thrown as typed class instances instead of`,
115
- ` * generic \`ClientRequestError\`s.`,
115
+ ` * generic \`ClientHttpError\`s.`,
116
116
  ` */`,
117
117
  `export function ${clientFactoryName}(`,
118
118
  ` config: Omit<CreateClientConfig<ReturnType<typeof ${factoryName}>>, 'scopes' | 'errorRegistry'>`,