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
package/README.md CHANGED
@@ -52,6 +52,8 @@ const user2 = await procedure({}, { userId: '456' })
52
52
 
53
53
  - **[Typed Error Handling](docs/http-integrations.md#error-handling)** — Declarative `defineErrorTaxonomy` maps thrown error classes to HTTP responses across every builder; generated clients throw typed class instances you can catch with `instanceof`.
54
54
 
55
+ - **[Client Error Handling](docs/client-error-handling.md)** — Normalized framework error classes (`ClientHttpError`, `ClientNetworkError`, `ClientTimeoutError`, `ClientAbortError`, `ClientParseError`), `.safe()` Result API for exhaustive narrowing without try/catch, and augmentable `ClientErrorMap` for custom error categories.
56
+
55
57
  - **[AI Agent Setup](docs/ai-agent-setup.md)** — Built-in configuration for Claude Code, Cursor, and GitHub Copilot. Auto-updates on `npm install`.
56
58
 
57
59
  Full documentation is available on [GitHub](https://github.com/thermsio/ts-procedures).
@@ -175,7 +175,8 @@ The npm package ships user-facing documentation with narrative explanations and
175
175
  | `docs/streaming.md` | Streaming procedures, AbortSignal, SSE patterns |
176
176
  | `docs/http-integrations.md` | Express RPC, Hono RPC/Stream/API builders, **error taxonomy (canonical)**, DocRegistry (unified constructor, `.documentError()`) |
177
177
  | `docs/client-and-codegen.md` | Client code generation, `createApiClient`/`createClient`, typed error dispatch, per-route `Errors` unions, per-call options, client-level defaults, typed RequestMeta augmentation, CLI options |
178
- | `CHANGELOG.md` | Release notes see `[6.0.0]` for the current peer error-handling model (taxonomy + `onError` + `onRequestError`), per-route errors, and client runtime error classes. |
178
+ | `docs/client-error-handling.md` | **Canonical guide** for 7.0+ client error surface: normalized error classes, `.safe()` Result API, `ClientErrorMap` augmentation, custom `ErrorClassifier`, migration from `ClientRequestError`. |
179
+ | `CHANGELOG.md` | Release notes — see `[7.0.0]` for the safe-result API, new error classes, and `ClientRequestError` → `ClientHttpError` rename. See `[6.0.0]` for the peer error-handling model (taxonomy + `onError` + `onRequestError`). |
179
180
 
180
181
  ## Workflow
181
182
 
@@ -612,7 +612,7 @@ Create('GetUser', {
612
612
 
613
613
  ## 20. Hand-writing `onError` instanceof ladders
614
614
 
615
- **Problem:** Writing manual `instanceof` ladders inside `onError` to map each error class to a status + body. Every builder gets its own copy; generated clients see opaque `ClientRequestError` objects instead of typed instances; response shapes drift between routes.
615
+ **Problem:** Writing manual `instanceof` ladders inside `onError` to map each error class to a status + body. Every builder gets its own copy; generated clients see opaque `ClientHttpError` objects instead of typed instances; response shapes drift between routes.
616
616
 
617
617
  ```typescript
618
618
  // BAD — duplicated across builders, invisible to generated clients
@@ -659,6 +659,42 @@ The same `errors` / `unknownError` shape plugs into every builder (`HonoAPIAppBu
659
659
 
660
660
  ---
661
661
 
662
+ ## 21. Catching raw `DOMException` or `TypeError` from generated callables
663
+
664
+ **Problem:** Checking `instanceof DOMException` or `instanceof TypeError` in `catch` blocks after calling a generated RPC/API callable. The framework normalizes these platform errors into typed framework classes at the `executeCall` boundary — after 7.0.0, raw platform errors will not reach your `catch` block from a generated callable. The original platform error is preserved on `error.cause` if you need it.
665
+
666
+ ```typescript
667
+ // BAD — platform error classes won't reach here from a generated callable
668
+ catch (e) {
669
+ if (e instanceof DOMException && e.name === 'AbortError') {
670
+ // Was this a timeout or a user cancel? Hard to tell.
671
+ }
672
+ if (e instanceof TypeError) {
673
+ // Network failure? Something else?
674
+ }
675
+ }
676
+ ```
677
+
678
+ **Fix:** Catch the framework error classes instead.
679
+
680
+ ```typescript
681
+ // GOOD
682
+ import { ClientTimeoutError, ClientAbortError, ClientNetworkError } from 'ts-procedures/client'
683
+
684
+ catch (e) {
685
+ if (e instanceof ClientTimeoutError) Alerts.error('Timed out')
686
+ else if (e instanceof ClientAbortError) { /* user cancelled — silent */ }
687
+ else if (e instanceof ClientNetworkError) Alerts.error('Network error')
688
+ else throw e // unknown — let it propagate
689
+ }
690
+ ```
691
+
692
+ Or use the `.safe()` sibling for exhaustive Result-based narrowing (see `patterns.md` — "Handling errors in client code").
693
+
694
+ **Why:** The framework wraps `AbortError` (from timeout or signal) into `ClientTimeoutError` / `ClientAbortError` and `TypeError`/fetch network failures into `ClientNetworkError`. This gives you distinct, meaningful types and removes ambiguity. Streams keep the throwing form — `.safe()` is only available on RPC and API callables.
695
+
696
+ ---
697
+
662
698
  ## Summary Table
663
699
 
664
700
  | # | Anti-Pattern | Risk | Severity |
@@ -683,3 +719,4 @@ The same `errors` / `unknownError` shape plugs into every builder (`HonoAPIAppBu
683
719
  | 18 | Both schema.params and schema.input | ProcedureRegistrationError at startup | CRITICAL |
684
720
  | 19 | Mismatched path param names | Build-time error or confusing validation failures | CRITICAL |
685
721
  | 20 | Hand-writing onError instanceof ladders | Drifting response shapes, untyped client errors | WARNING |
722
+ | 21 | Catching raw DOMException/TypeError from generated callables | Framework normalizes these; raw platform errors won't reach catch blocks after 7.0.0 | WARNING |
@@ -354,7 +354,7 @@ Returns a typed error instance when:
354
354
  - `body` is an object with a string `name`, AND
355
355
  - `registry[body.name].fromResponse(body, meta)` returns an `Error` subclass.
356
356
 
357
- Otherwise returns `null`; callers fall back to `ClientRequestError`.
357
+ Otherwise returns `null`; callers fall back to `ClientHttpError`.
358
358
 
359
359
  ### CreateClientConfig.errorRegistry
360
360
 
@@ -369,6 +369,227 @@ Threaded into both `executeCall` and `executeStream`. The generated `create${Ser
369
369
 
370
370
  ---
371
371
 
372
+ ## Client Error Classes (7.0+)
373
+
374
+ All framework errors are normalized at the `executeCall` / `executeStream` boundary — raw `TypeError` and `DOMException` from the platform never reach consumer catch blocks.
375
+
376
+ ### ClientHttpError
377
+
378
+ ```typescript
379
+ class ClientHttpError extends Error {
380
+ readonly status: number
381
+ readonly cause?: unknown
382
+ }
383
+ ```
384
+
385
+ Thrown for non-2xx HTTP responses whose body does not match any registry entry (or when no registry is configured). Renamed from `ClientRequestError` in 7.0.0; the old name is re-exported as a deprecated alias for one minor cycle.
386
+
387
+ ### ClientNetworkError
388
+
389
+ ```typescript
390
+ class ClientNetworkError extends Error {
391
+ readonly cause?: unknown
392
+ }
393
+ ```
394
+
395
+ Thrown when the network request itself fails (e.g., DNS failure, connection refused — a `TypeError` from `fetch`). The original error is available on `cause`.
396
+
397
+ ### ClientTimeoutError
398
+
399
+ ```typescript
400
+ class ClientTimeoutError extends Error {
401
+ readonly cause?: unknown
402
+ }
403
+ ```
404
+
405
+ Thrown when the request is aborted by a timeout (`AbortSignal.timeout`). The original `DOMException` is available on `cause`.
406
+
407
+ ### ClientAbortError
408
+
409
+ ```typescript
410
+ class ClientAbortError extends Error {
411
+ readonly cause?: unknown
412
+ }
413
+ ```
414
+
415
+ Thrown when the request is aborted by a caller-supplied `AbortSignal`. The original `DOMException` is available on `cause`.
416
+
417
+ ### ClientParseError
418
+
419
+ ```typescript
420
+ class ClientParseError extends Error {
421
+ readonly cause?: unknown
422
+ }
423
+ ```
424
+
425
+ Thrown when the response body cannot be parsed as JSON (e.g., the server returned HTML for a non-2xx status). The parse error is available on `cause`.
426
+
427
+ ### ClientPathParamError
428
+
429
+ ```typescript
430
+ class ClientPathParamError extends Error {}
431
+ ```
432
+
433
+ Thrown at call time when a required path parameter is missing in the options. This is a programming error (the generated callable could not interpolate the URL path).
434
+
435
+ ### ClientStreamError
436
+
437
+ ```typescript
438
+ class ClientStreamError extends Error {
439
+ readonly cause?: unknown
440
+ }
441
+ ```
442
+
443
+ Thrown for stream-level transport errors (SSE connection failures, unexpected stream termination).
444
+
445
+ ---
446
+
447
+ ## Safe-Result API (7.0+)
448
+
449
+ ### Result\<T, ETyped\>
450
+
451
+ ```typescript
452
+ type Result<T, ETyped = never> =
453
+ | { ok: true; value: T }
454
+ | FrameworkFailure<ETyped>
455
+ ```
456
+
457
+ Returned by every generated `.safe()` callable. When `ok` is `true`, `value` is the typed response. When `ok` is `false`, the failure is discriminated by `kind`.
458
+
459
+ ### ResultNoTyped\<T\>
460
+
461
+ ```typescript
462
+ type ResultNoTyped<T> = Result<T, never>
463
+ ```
464
+
465
+ Convenience alias when no typed error registry is wired (all failures are framework-level).
466
+
467
+ ### FrameworkFailure\<ETyped\>
468
+
469
+ ```typescript
470
+ type FrameworkFailure<ETyped = never> =
471
+ | { ok: false; kind: 'typed'; error: ETyped }
472
+ | { ok: false; kind: 'http'; error: ClientHttpError }
473
+ | { ok: false; kind: 'network'; error: ClientNetworkError }
474
+ | { ok: false; kind: 'timeout'; error: ClientTimeoutError }
475
+ | { ok: false; kind: 'aborted'; error: ClientAbortError }
476
+ | { ok: false; kind: 'parse'; error: ClientParseError }
477
+ | { ok: false; kind: 'usage'; error: ClientPathParamError }
478
+ | { ok: false; kind: 'unknown'; error: unknown }
479
+ // + any augmented kinds from ClientErrorMap
480
+ ```
481
+
482
+ The failure branch of `Result`. Discriminate on `kind` to narrow `error`.
483
+
484
+ ### ClientErrorMap
485
+
486
+ ```typescript
487
+ interface ClientErrorMap {
488
+ // empty — designed for declaration merging
489
+ }
490
+ ```
491
+
492
+ Augment this interface to add custom `kind` entries to `FrameworkFailure`. Example:
493
+
494
+ ```typescript
495
+ declare module 'ts-procedures/client' {
496
+ interface ClientErrorMap {
497
+ rateLimit: RateLimitError
498
+ }
499
+ }
500
+ ```
501
+
502
+ After augmentation, `r.kind === 'rateLimit'` is a valid branch in a `switch` over `FrameworkFailure`.
503
+
504
+ ### ClientInstance.safeCall\<TResponse, ETyped\>(descriptor, options)
505
+
506
+ ```typescript
507
+ interface ClientInstance {
508
+ safeCall<TResponse, ETyped = never>(
509
+ descriptor: CallDescriptor,
510
+ options?: ProcedureCallOptions
511
+ ): Promise<Result<TResponse, ETyped>>
512
+ }
513
+ ```
514
+
515
+ The underlying method that all generated `.safe()` callables delegate to. When `ETyped` is provided, it is the type of the `typed` failure branch (the registry-dispatched error class). Streams do not expose a `.safe()` sibling — `safeCall` applies to RPC and API calls only.
516
+
517
+ ### ClientInstance.bindCallable / bindCallableTyped
518
+
519
+ Helper methods used by codegen to wire each generated callable. Returns a callable function with a `.safe` sibling. The returned function's `.name` is set to `descriptor.name` for nicer stack traces.
520
+
521
+ ```typescript
522
+ // For routes without typed errors
523
+ bindCallable<TParams, TResponse>(
524
+ descriptor: Omit<CallDescriptor, 'params'>,
525
+ ): {
526
+ (params: TParams, options?: ProcedureCallOptions): Promise<TResponse>
527
+ safe(params: TParams, options?: ProcedureCallOptions): Promise<ResultNoTyped<TResponse>>
528
+ }
529
+
530
+ // For routes with typed errors
531
+ bindCallableTyped<TParams, TResponse, ETyped>(
532
+ descriptor: Omit<CallDescriptor, 'params'>,
533
+ ): {
534
+ (params: TParams, options?: ProcedureCallOptions): Promise<TResponse>
535
+ safe(params: TParams, options?: ProcedureCallOptions): Promise<Result<TResponse, ETyped>>
536
+ }
537
+ ```
538
+
539
+ `descriptor` is `Omit<CallDescriptor, 'params'>` — params is supplied at call time. Codegen emits a compact one-liner per route:
540
+
541
+ ```typescript
542
+ // Generated (no declared errors):
543
+ GetUser: client.bindCallable<Users.GetUser.Params, Users.GetUser.Response>({
544
+ name: 'GetUser', scope: 'users', path: '/users/1', method: 'post', kind: 'rpc',
545
+ }),
546
+
547
+ // Generated (with declared errors):
548
+ GetUser: client.bindCallableTyped<Users.GetUser.Params, Users.GetUser.Response, Users.GetUser.Errors>({
549
+ name: 'GetUser', scope: 'users', path: '/users/1', method: 'post', kind: 'rpc',
550
+ }),
551
+ ```
552
+
553
+ Hand-written clients that mimic the codegen pattern can also use these helpers directly on the `ClientInstance` passed to the `scopes` factory.
554
+
555
+ ---
556
+
557
+ ## Error Classifier API (7.0+)
558
+
559
+ ### defaultClassifyError
560
+
561
+ ```typescript
562
+ function defaultClassifyError(err: unknown, ctx: ClassifyErrorContext): ClassifiedError | null
563
+ ```
564
+
565
+ The built-in classifier that maps raw platform errors to framework classes. Returns a `ClassifiedError` (with `kind` + `error`) when the error is recognized, or `null` to fall through to `unknown`. Pass a custom `ErrorClassifier` to `createFetchAdapter` / `ClientAdapter` to override or extend.
566
+
567
+ ### ErrorClassifier
568
+
569
+ ```typescript
570
+ type ErrorClassifier = (err: unknown, ctx: ClassifyErrorContext) => ClassifiedError | null
571
+ ```
572
+
573
+ ### ClassifyErrorContext
574
+
575
+ ```typescript
576
+ interface ClassifyErrorContext {
577
+ request: AdapterRequest
578
+ signal?: AbortSignal
579
+ }
580
+ ```
581
+
582
+ ### ClassifiedError
583
+
584
+ ```typescript
585
+ interface ClassifiedError {
586
+ kind: keyof ClientErrorMap | 'network' | 'timeout' | 'aborted' | 'parse' | 'unknown'
587
+ error: Error
588
+ }
589
+ ```
590
+
591
+ ---
592
+
372
593
  ## Schema Utilities
373
594
 
374
595
  ### extractJsonSchema(libSchema)
@@ -986,12 +1207,14 @@ Creates a `ClientAdapter` using the global `fetch` API.
986
1207
  ```typescript
987
1208
  function createFetchAdapter(options?: {
988
1209
  fetch?: typeof globalThis.fetch
1210
+ classifyError?: ErrorClassifier
989
1211
  }): ClientAdapter
990
1212
  ```
991
1213
 
992
1214
  ### Parameters
993
1215
 
994
1216
  - `options.fetch` — Optional custom fetch implementation. Defaults to `globalThis.fetch`.
1217
+ - `options.classifyError` — Optional classifier override. When provided, replaces `defaultClassifyError` for mapping raw platform errors to framework classes. Use this to add custom `ClientErrorMap` categories or to adjust the default mapping.
995
1218
 
996
1219
  ### Return Value
997
1220
 
@@ -1212,8 +1435,35 @@ import { HonoAPIAppBuilder } from 'ts-procedures/hono-api'
1212
1435
  import type { APIConfig, APIHttpRouteDoc, APIInput, HttpMethod } from 'ts-procedures/hono-api'
1213
1436
 
1214
1437
  // Client
1215
- import { createClient, createFetchAdapter } from 'ts-procedures/client'
1216
- import type { ClientAdapter, ClientHooks, ClientInstance, TypedStream } from 'ts-procedures/client'
1438
+ import {
1439
+ createClient,
1440
+ createFetchAdapter,
1441
+ dispatchTypedError,
1442
+ defaultClassifyError,
1443
+ // Error classes (7.0+)
1444
+ ClientHttpError, // renamed from ClientRequestError; deprecated alias retained one cycle
1445
+ ClientNetworkError,
1446
+ ClientTimeoutError,
1447
+ ClientAbortError,
1448
+ ClientParseError,
1449
+ ClientPathParamError,
1450
+ ClientStreamError,
1451
+ } from 'ts-procedures/client'
1452
+ import type {
1453
+ ClientAdapter,
1454
+ ClientHooks,
1455
+ ClientInstance,
1456
+ TypedStream,
1457
+ // Safe-result types (7.0+)
1458
+ Result,
1459
+ ResultNoTyped,
1460
+ FrameworkFailure,
1461
+ ClientErrorMap,
1462
+ // Classifier types (7.0+)
1463
+ ErrorClassifier,
1464
+ ClassifyErrorContext,
1465
+ ClassifiedError,
1466
+ } from 'ts-procedures/client'
1217
1467
 
1218
1468
  // Code generation
1219
1469
  import { generateClient } from 'ts-procedures/codegen'
@@ -242,7 +242,7 @@ try {
242
242
  } else if (err instanceof ApiErrors.ApiProcedureError) {
243
243
  // Catch-all for any generated service error.
244
244
  } else {
245
- // Transport error (network, non-JSON body) — still a ClientRequestError.
245
+ // Transport error (network, non-JSON body) — still a ClientHttpError.
246
246
  }
247
247
  }
248
248
  ```
@@ -263,13 +263,71 @@ export namespace Users {
263
263
  catch (err: unknown) {
264
264
  // Cast is manual today (TS has no typed throws); the union documents
265
265
  // which errors this specific route can throw.
266
- const e = err as Users.GetUser.Errors | ClientRequestError
266
+ const e = err as Users.GetUser.Errors | ClientHttpError
267
267
  if (e instanceof ApiErrors.UseCaseError) { /* ... */ }
268
268
  }
269
269
  ```
270
270
 
271
271
  ---
272
272
 
273
+ ## Handling errors in client code
274
+
275
+ The framework normalizes platform errors (`TypeError`, `DOMException`) into framework error classes at the `executeCall` boundary. Consumers see typed framework classes, not raw platform errors.
276
+
277
+ ### Throwing form (canonical)
278
+
279
+ ```typescript
280
+ import { ClientHttpError, ClientTimeoutError, ClientNetworkError, ClientAbortError } from 'ts-procedures/client'
281
+ import { createApiClient, ApiErrors, createFetchAdapter } from './generated'
282
+
283
+ const api = createApiClient({ adapter: createFetchAdapter(), basePath: 'https://api.example.com' })
284
+
285
+ try {
286
+ const response = await api.records.DownloadRecord(params, { timeout: 60_000 })
287
+ if (response.pdfBase64) downloadBase64AsPdf(response.pdfBase64)
288
+ } catch (err) {
289
+ if (err instanceof ApiErrors.UseCaseError) {
290
+ Alerts.error(err.body.message)
291
+ } else if (err instanceof ClientHttpError) {
292
+ Alerts.error(`Server returned ${err.status}`)
293
+ } else if (err instanceof ClientTimeoutError) {
294
+ Alerts.error('Request timed out')
295
+ } else if (err instanceof ClientNetworkError) {
296
+ Alerts.error('Network error')
297
+ } else if (err instanceof ClientAbortError) {
298
+ // user cancelled — silent
299
+ } else {
300
+ throw err // programmer/framework bug
301
+ }
302
+ }
303
+ ```
304
+
305
+ ### Safe form (Result-returning, exhaustive narrowing)
306
+
307
+ Every RPC and API callable has a `.safe()` sibling that returns `Result<T, ETyped>` instead of throwing:
308
+
309
+ ```typescript
310
+ const r = await api.records.DownloadRecord.safe(params, { timeout: 60_000 })
311
+ if (r.ok) {
312
+ if (r.value.pdfBase64) downloadBase64AsPdf(r.value.pdfBase64)
313
+ return
314
+ }
315
+ switch (r.kind) {
316
+ case 'typed': /* registry-dispatched typed error, e.g. ApiErrors.UseCaseError */; break
317
+ case 'http': Alerts.error(`Server ${r.error.status}`); break
318
+ case 'network': Alerts.error('Network error'); break
319
+ case 'timeout': Alerts.error('Timed out'); break
320
+ case 'aborted': break // user cancelled
321
+ case 'parse':
322
+ case 'usage': throw r.error // programmer/framework bug
323
+ case 'unknown': console.error(r.error); break
324
+ }
325
+ ```
326
+
327
+ Streams keep the throwing form (no `.safe`). Custom error categories can be added via `ClientErrorMap` interface augmentation — see `docs/client-error-handling.md`.
328
+
329
+ ---
330
+
273
331
  ## Stream Procedures
274
332
 
275
333
  ```typescript
@@ -50,7 +50,7 @@ export const {{name}}Client = createApiClient({
50
50
  // } catch (err) {
51
51
  // if (err instanceof ApiErrors.UseCaseError) { /* err.body typed; err.status, procedureName, scope */ }
52
52
  // else if (err instanceof ApiErrors.ApiProcedureError) { /* catch-all for service errors */ }
53
- // else { /* transport error — ClientRequestError */ }
53
+ // else { /* transport error — ClientHttpError */ }
54
54
  // }
55
55
 
56
56
  // --- RPC call example ---
@@ -280,6 +280,8 @@ try {
280
280
 
281
281
  Per-route error unions: routes with `errors: [...]` get an `Errors` type in their namespace (e.g. `Users.GetUser.Errors = ApiErrors.UseCaseError | ApiErrors.AuthError`).
282
282
 
283
+ - Generated client error handling: catch the framework classes (`ClientHttpError`, `ClientNetworkError`, `ClientTimeoutError`, `ClientAbortError`), not raw `DOMException`/`TypeError` — the framework normalizes platform errors at the `executeCall` boundary, so raw platform errors no longer reach `catch` blocks after 7.0.0 (original is on `error.cause`). Use the `.safe()` sibling on RPC/API callables for an exhaustive `Result<T, E>` switch (`ok`, `typed`, `http`, `network`, `timeout`, `aborted`, `parse`, `usage`, `unknown`). Streams keep the throwing form — no `.safe()`.
284
+
283
285
  ## Lifecycle Hook Order
284
286
 
285
287
  ### Standard RPC
@@ -327,6 +329,7 @@ onRequestStart → factoryContext() → validation → onStreamStart → handler
327
329
  9. **Never assume extra params fields survive** — `removeAdditional: true` strips them
328
330
  10. **Never manually parse types AJV coerces** — `coerceTypes: true` handles it
329
331
  11. **Never define both schema.params and schema.input** — mutually exclusive, throws ProcedureRegistrationError
332
+ 12. **Never catch raw DOMException/TypeError from generated callables** — catch `ClientTimeoutError`, `ClientAbortError`, `ClientNetworkError` instead; or use `.safe()` for Result-based narrowing
330
333
 
331
334
  ## Testing
332
335
 
@@ -280,6 +280,8 @@ try {
280
280
 
281
281
  Per-route error unions: routes with `errors: [...]` get an `Errors` type in their namespace (e.g. `Users.GetUser.Errors = ApiErrors.UseCaseError | ApiErrors.AuthError`).
282
282
 
283
+ - Generated client error handling: catch the framework classes (`ClientHttpError`, `ClientNetworkError`, `ClientTimeoutError`, `ClientAbortError`), not raw `DOMException`/`TypeError` — the framework normalizes platform errors at the `executeCall` boundary, so raw platform errors no longer reach `catch` blocks after 7.0.0 (original is on `error.cause`). Use the `.safe()` sibling on RPC/API callables for an exhaustive `Result<T, E>` switch (`ok`, `typed`, `http`, `network`, `timeout`, `aborted`, `parse`, `usage`, `unknown`). Streams keep the throwing form — no `.safe()`.
284
+
283
285
  ## Lifecycle Hook Order
284
286
 
285
287
  ### Standard RPC
@@ -327,6 +329,7 @@ onRequestStart → factoryContext() → validation → onStreamStart → handler
327
329
  9. **Never assume extra params fields survive** — `removeAdditional: true` strips them
328
330
  10. **Never manually parse types AJV coerces** — `coerceTypes: true` handles it
329
331
  11. **Never define both schema.params and schema.input** — mutually exclusive, throws ProcedureRegistrationError
332
+ 12. **Never catch raw DOMException/TypeError from generated callables** — catch `ClientTimeoutError`, `ClientAbortError`, `ClientNetworkError` instead; or use `.safe()` for Result-based narrowing
330
333
 
331
334
  ## Testing
332
335
 
@@ -0,0 +1,10 @@
1
+ declare class RateLimitError extends Error {
2
+ retryAfter: number;
3
+ constructor(retryAfter: number);
4
+ }
5
+ declare module './types.js' {
6
+ interface ClientErrorMap {
7
+ rateLimited: RateLimitError;
8
+ }
9
+ }
10
+ export {};
@@ -0,0 +1,14 @@
1
+ import { describe, it, expectTypeOf } from 'vitest';
2
+ class RateLimitError extends Error {
3
+ retryAfter;
4
+ constructor(retryAfter) {
5
+ super('rate limited');
6
+ this.retryAfter = retryAfter;
7
+ }
8
+ }
9
+ describe('ClientErrorMap augmentation', () => {
10
+ it('adds rateLimited kind to Result', () => {
11
+ expectTypeOf().toEqualTypeOf();
12
+ });
13
+ });
14
+ //# sourceMappingURL=augment-error-map.test-d.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"augment-error-map.test-d.js","sourceRoot":"","sources":["../../src/client/augment-error-map.test-d.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,EAAE,EAAE,YAAY,EAAE,MAAM,QAAQ,CAAA;AAGnD,MAAM,cAAe,SAAQ,KAAK;IACb;IAAnB,YAAmB,UAAkB;QACnC,KAAK,CAAC,cAAc,CAAC,CAAA;QADJ,eAAU,GAAV,UAAU,CAAQ;IAErC,CAAC;CACF;AAQD,QAAQ,CAAC,6BAA6B,EAAE,GAAG,EAAE;IAC3C,EAAE,CAAC,iCAAiC,EAAE,GAAG,EAAE;QAGzC,YAAY,EAAwB,CAAC,aAAa,EAAkB,CAAA;IACtE,CAAC,CAAC,CAAA;AACJ,CAAC,CAAC,CAAA"}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,132 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { createClient } from './index.js';
3
+ import { ClientHttpError, ClientNetworkError } from './errors.js';
4
+ const okAdapter = {
5
+ request: vi.fn(async () => ({ status: 200, headers: {}, body: { id: '42' } })),
6
+ stream: vi.fn(async () => { throw new Error('n/a'); }),
7
+ };
8
+ describe('ClientInstance.bindCallable', () => {
9
+ it('returns a callable that throws on failure', async () => {
10
+ const client = createClient({
11
+ adapter: { request: vi.fn(async () => { throw new TypeError('x'); }), stream: vi.fn(async () => { throw new Error('n/a'); }) },
12
+ basePath: 'https://api.x',
13
+ scopes: (c) => ({
14
+ getUser: c.bindCallable({
15
+ name: 'GetUser', scope: 'users', path: '/users/:id', method: 'get', kind: 'rpc',
16
+ }),
17
+ }),
18
+ });
19
+ await expect(client.getUser({ id: '1' })).rejects.toBeInstanceOf(ClientNetworkError);
20
+ });
21
+ it('exposes .safe returning ResultNoTyped', async () => {
22
+ const client = createClient({
23
+ adapter: { request: vi.fn(async () => ({ status: 500, headers: {}, body: {} })), stream: vi.fn(async () => { throw new Error('n/a'); }) },
24
+ basePath: 'https://api.x',
25
+ scopes: (c) => ({
26
+ getUser: c.bindCallable({
27
+ name: 'GetUser', scope: 'users', path: '/users/:id', method: 'get', kind: 'rpc',
28
+ }),
29
+ }),
30
+ });
31
+ const r = await client.getUser.safe({ id: '1' });
32
+ expect(r.ok).toBe(false);
33
+ if (!r.ok) {
34
+ expect(r.kind).toBe('http');
35
+ expect(r.error).toBeInstanceOf(ClientHttpError);
36
+ }
37
+ });
38
+ it('preserves descriptor.name as fn.name for stack traces', () => {
39
+ const client = createClient({
40
+ adapter: okAdapter,
41
+ basePath: 'https://api.x',
42
+ scopes: (c) => ({
43
+ getUser: c.bindCallable({
44
+ name: 'GetUser', scope: 'users', path: '/users/:id', method: 'get', kind: 'rpc',
45
+ }),
46
+ }),
47
+ });
48
+ expect(client.getUser.name).toBe('GetUser');
49
+ });
50
+ it('resolves the response value on success', async () => {
51
+ const client = createClient({
52
+ adapter: { request: vi.fn(async () => ({ status: 200, headers: {}, body: { id: '42' } })), stream: vi.fn(async () => { throw new Error('n/a'); }) },
53
+ basePath: 'https://api.x',
54
+ scopes: (c) => ({
55
+ getUser: c.bindCallable({
56
+ name: 'GetUser', scope: 'users', path: '/users/:id', method: 'get', kind: 'rpc',
57
+ }),
58
+ }),
59
+ });
60
+ const result = await client.getUser({ id: '1' });
61
+ expect(result).toEqual({ id: '42' });
62
+ });
63
+ it('.safe resolves with ok: true on success', async () => {
64
+ const client = createClient({
65
+ adapter: { request: vi.fn(async () => ({ status: 200, headers: {}, body: { id: '42' } })), stream: vi.fn(async () => { throw new Error('n/a'); }) },
66
+ basePath: 'https://api.x',
67
+ scopes: (c) => ({
68
+ getUser: c.bindCallable({
69
+ name: 'GetUser', scope: 'users', path: '/users/:id', method: 'get', kind: 'rpc',
70
+ }),
71
+ }),
72
+ });
73
+ const r = await client.getUser.safe({ id: '1' });
74
+ expect(r.ok).toBe(true);
75
+ if (r.ok) {
76
+ expect(r.value).toEqual({ id: '42' });
77
+ }
78
+ });
79
+ });
80
+ describe('ClientInstance.bindCallableTyped', () => {
81
+ it('exposes .safe returning Result with typed errors', async () => {
82
+ class ApiUseCaseError extends Error {
83
+ status;
84
+ constructor(status) {
85
+ super('use-case');
86
+ this.status = status;
87
+ }
88
+ }
89
+ const client = createClient({
90
+ adapter: { request: vi.fn(async () => ({ status: 500, headers: {}, body: {} })), stream: vi.fn(async () => { throw new Error('n/a'); }) },
91
+ basePath: 'https://api.x',
92
+ scopes: (c) => ({
93
+ getUser: c.bindCallableTyped({
94
+ name: 'GetUser', scope: 'users', path: '/users/:id', method: 'get', kind: 'rpc',
95
+ }),
96
+ }),
97
+ });
98
+ const r = await client.getUser.safe({ id: '1' });
99
+ expect(r.ok).toBe(false);
100
+ // Falls through to 'http' since no registry was wired — the test verifies
101
+ // the .safe surface exists, not the typed dispatch path.
102
+ if (!r.ok) {
103
+ expect(['http', 'typed', 'network', 'timeout', 'aborted', 'parse', 'usage', 'unknown']).toContain(r.kind);
104
+ }
105
+ });
106
+ it('preserves descriptor.name as fn.name for stack traces', () => {
107
+ const client = createClient({
108
+ adapter: okAdapter,
109
+ basePath: 'https://api.x',
110
+ scopes: (c) => ({
111
+ getUser: c.bindCallableTyped({
112
+ name: 'GetUser', scope: 'users', path: '/users/:id', method: 'get', kind: 'rpc',
113
+ }),
114
+ }),
115
+ });
116
+ expect(client.getUser.name).toBe('GetUser');
117
+ });
118
+ it('resolves the response value on success', async () => {
119
+ const client = createClient({
120
+ adapter: { request: vi.fn(async () => ({ status: 200, headers: {}, body: { id: '99' } })), stream: vi.fn(async () => { throw new Error('n/a'); }) },
121
+ basePath: 'https://api.x',
122
+ scopes: (c) => ({
123
+ getUser: c.bindCallableTyped({
124
+ name: 'GetUser', scope: 'users', path: '/users/:id', method: 'get', kind: 'rpc',
125
+ }),
126
+ }),
127
+ });
128
+ const result = await client.getUser({ id: '1' });
129
+ expect(result).toEqual({ id: '99' });
130
+ });
131
+ });
132
+ //# sourceMappingURL=bind-callable.test.js.map