ts-procedures 6.2.0 → 7.0.0-beta.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.
- package/README.md +2 -0
- package/agent_config/claude-code/skills/ts-procedures/SKILL.md +2 -1
- package/agent_config/claude-code/skills/ts-procedures/anti-patterns.md +38 -1
- package/agent_config/claude-code/skills/ts-procedures/api-reference.md +215 -3
- package/agent_config/claude-code/skills/ts-procedures/patterns.md +60 -2
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/client.md +1 -1
- package/agent_config/copilot/copilot-instructions.md +3 -0
- package/agent_config/cursor/cursorrules +3 -0
- package/build/client/augment-error-map.test-d.d.ts +10 -0
- package/build/client/augment-error-map.test-d.js +14 -0
- package/build/client/augment-error-map.test-d.js.map +1 -0
- package/build/client/call.d.ts +14 -2
- package/build/client/call.js +96 -9
- package/build/client/call.js.map +1 -1
- package/build/client/call.test.js +50 -1
- package/build/client/call.test.js.map +1 -1
- package/build/client/classify-error.d.ts +11 -0
- package/build/client/classify-error.js +49 -0
- package/build/client/classify-error.js.map +1 -0
- package/build/client/classify-error.test.d.ts +1 -0
- package/build/client/classify-error.test.js +55 -0
- package/build/client/classify-error.test.js.map +1 -0
- package/build/client/error-dispatch.d.ts +1 -1
- package/build/client/error-dispatch.js +1 -1
- package/build/client/errors.d.ts +55 -4
- package/build/client/errors.js +54 -7
- package/build/client/errors.js.map +1 -1
- package/build/client/errors.test.js +89 -4
- package/build/client/errors.test.js.map +1 -1
- package/build/client/fetch-adapter.d.ts +2 -1
- package/build/client/fetch-adapter.js +2 -1
- package/build/client/fetch-adapter.js.map +1 -1
- package/build/client/fetch-adapter.test.js +12 -0
- package/build/client/fetch-adapter.test.js.map +1 -1
- package/build/client/index.d.ts +5 -3
- package/build/client/index.js +15 -3
- package/build/client/index.js.map +1 -1
- package/build/client/resolve-options.d.ts +32 -1
- package/build/client/resolve-options.js +32 -16
- package/build/client/resolve-options.js.map +1 -1
- package/build/client/resolve-options.test.js +67 -6
- package/build/client/resolve-options.test.js.map +1 -1
- package/build/client/result-type.test-d.d.ts +1 -0
- package/build/client/result-type.test-d.js +28 -0
- package/build/client/result-type.test-d.js.map +1 -0
- package/build/client/safe-call.test.d.ts +1 -0
- package/build/client/safe-call.test.js +137 -0
- package/build/client/safe-call.test.js.map +1 -0
- package/build/client/stream.d.ts +1 -1
- package/build/client/stream.js +22 -8
- package/build/client/stream.js.map +1 -1
- package/build/client/stream.test.js +11 -1
- package/build/client/stream.test.js.map +1 -1
- package/build/client/types.d.ts +96 -3
- package/build/codegen/bundle-size.test.d.ts +1 -0
- package/build/codegen/bundle-size.test.js +68 -0
- package/build/codegen/bundle-size.test.js.map +1 -0
- package/build/codegen/e2e.test.js +103 -1
- package/build/codegen/e2e.test.js.map +1 -1
- package/build/codegen/emit-client-runtime.js +7 -0
- package/build/codegen/emit-client-runtime.js.map +1 -1
- package/build/codegen/emit-client-runtime.test.js +6 -2
- package/build/codegen/emit-client-runtime.test.js.map +1 -1
- package/build/codegen/emit-client-types.d.ts +7 -2
- package/build/codegen/emit-client-types.js +29 -8
- package/build/codegen/emit-client-types.js.map +1 -1
- package/build/codegen/emit-client-types.test.js +20 -8
- package/build/codegen/emit-client-types.test.js.map +1 -1
- package/build/codegen/emit-errors.d.ts +1 -1
- package/build/codegen/emit-errors.js +1 -1
- package/build/codegen/emit-index.js +1 -1
- package/build/codegen/emit-index.js.map +1 -1
- package/build/codegen/emit-scope.js +94 -26
- package/build/codegen/emit-scope.js.map +1 -1
- package/build/codegen/emit-scope.test.js +297 -2
- package/build/codegen/emit-scope.test.js.map +1 -1
- package/docs/client-and-codegen.md +77 -7
- package/docs/client-error-handling.md +357 -0
- package/docs/superpowers/plans/2026-04-29-safe-result-api.md +2293 -0
- package/docs/superpowers/specs/2026-04-29-safe-result-api-design.md +324 -0
- package/package.json +1 -1
- package/src/client/augment-error-map.test-d.ts +22 -0
- package/src/client/call.test.ts +65 -1
- package/src/client/call.ts +111 -9
- package/src/client/classify-error.test.ts +65 -0
- package/src/client/classify-error.ts +59 -0
- package/src/client/error-dispatch.ts +1 -1
- package/src/client/errors.test.ts +108 -4
- package/src/client/errors.ts +70 -7
- package/src/client/fetch-adapter.test.ts +15 -0
- package/src/client/fetch-adapter.ts +5 -2
- package/src/client/index.ts +39 -3
- package/src/client/resolve-options.test.ts +83 -5
- package/src/client/resolve-options.ts +61 -16
- package/src/client/result-type.test-d.ts +51 -0
- package/src/client/safe-call.test.ts +157 -0
- package/src/client/stream.test.ts +13 -1
- package/src/client/stream.ts +25 -8
- package/src/client/types.ts +112 -3
- package/src/codegen/bundle-size.test.ts +74 -0
- package/src/codegen/e2e.test.ts +108 -1
- package/src/codegen/emit-client-runtime.test.ts +7 -2
- package/src/codegen/emit-client-runtime.ts +7 -0
- package/src/codegen/emit-client-types.test.ts +22 -7
- package/src/codegen/emit-client-types.ts +35 -10
- package/src/codegen/emit-errors.ts +1 -1
- package/src/codegen/emit-index.ts +1 -1
- package/src/codegen/emit-scope.test.ts +324 -2
- package/src/codegen/emit-scope.ts +98 -36
|
@@ -60,7 +60,7 @@ const api = createClient({
|
|
|
60
60
|
adapter: createFetchAdapter(),
|
|
61
61
|
basePath: 'http://localhost:3000',
|
|
62
62
|
scopes: createApiBindings,
|
|
63
|
-
errorRegistry: ApiErrors.ApiErrorRegistry, // opt in to typed dispatch — omit to get the plain
|
|
63
|
+
errorRegistry: ApiErrors.ApiErrorRegistry, // opt in to typed dispatch — omit to get the plain ClientHttpError transport shape
|
|
64
64
|
})
|
|
65
65
|
```
|
|
66
66
|
|
|
@@ -127,6 +127,19 @@ const axiosAdapter: ClientAdapter = {
|
|
|
127
127
|
}
|
|
128
128
|
```
|
|
129
129
|
|
|
130
|
+
Both `ClientAdapter` and `createFetchAdapter` accept an optional `classifyError` field. When provided, the classifier overrides the default mapping from raw platform errors (`TypeError`, `DOMException`) to framework error classes. Pass a custom `ErrorClassifier` to emit your own `ClientErrorMap` categories.
|
|
131
|
+
|
|
132
|
+
```typescript
|
|
133
|
+
import { createFetchAdapter, defaultClassifyError } from 'ts-procedures/client'
|
|
134
|
+
|
|
135
|
+
const adapter = createFetchAdapter({
|
|
136
|
+
classifyError: (err, ctx) => {
|
|
137
|
+
// delegate to the built-in classifier for everything except your own cases
|
|
138
|
+
return defaultClassifyError(err, ctx)
|
|
139
|
+
},
|
|
140
|
+
})
|
|
141
|
+
```
|
|
142
|
+
|
|
130
143
|
## Per-Call Options
|
|
131
144
|
|
|
132
145
|
Every generated callable accepts an optional second argument for per-call configuration. The options bag covers both request-level config (timeout, cancellation, headers, base path) and hooks.
|
|
@@ -259,8 +272,8 @@ try {
|
|
|
259
272
|
// err.body is typed to UseCaseErrorBody; err.status, err.procedureName, err.scope available
|
|
260
273
|
} else if (err instanceof ApiErrors.ApiProcedureError) {
|
|
261
274
|
// Catch-all for any generated service error
|
|
262
|
-
} else if (err instanceof
|
|
263
|
-
// Transport-level error (unmatched body.name, or non-JSON response)
|
|
275
|
+
} else if (err instanceof ClientHttpError) {
|
|
276
|
+
// Transport-level error (unmatched body.name, or non-JSON response) — err.status, err.cause
|
|
264
277
|
}
|
|
265
278
|
}
|
|
266
279
|
```
|
|
@@ -282,7 +295,7 @@ export namespace Users {
|
|
|
282
295
|
// Consumer:
|
|
283
296
|
try { await api.users.GetUser({ pathParams: { id: 'x' } }) }
|
|
284
297
|
catch (err) {
|
|
285
|
-
const e = err as Users.GetUser.Errors |
|
|
298
|
+
const e = err as Users.GetUser.Errors | ClientHttpError // TS has no typed-throws; manual annotation
|
|
286
299
|
if (e instanceof ApiErrors.UseCaseError) { /* ... */ }
|
|
287
300
|
}
|
|
288
301
|
```
|
|
@@ -296,10 +309,42 @@ Route-errors that reference keys missing from `_errors.ts` are filtered out at c
|
|
|
296
309
|
1. If no registry, or body isn't an object with a string `name`, or no registry key matches → returns `null`.
|
|
297
310
|
2. Otherwise calls `registry[body.name].fromResponse(body, meta)` and returns the typed `Error` instance.
|
|
298
311
|
|
|
299
|
-
When the helper returns `null`, the call falls back to `
|
|
312
|
+
When the helper returns `null`, the call falls back to `ClientHttpError` — the plain transport-error shape, used for unmatched names, non-JSON bodies, and when no registry is configured.
|
|
300
313
|
|
|
301
314
|
Stream endpoints get the same treatment for **pre-stream** errors (thrown before the first yield — e.g. validation or auth). The fetch adapter's `stream()` eagerly parses a JSON body when status is non-2xx and exposes it via `AdapterStreamResponse.errorBody`, which `executeStream` passes through the registry. **Mid-stream** SSE error events continue to flow through `onMidStreamError` on the server and re-throw on the client without registry dispatch.
|
|
302
315
|
|
|
316
|
+
### Result-returning `.safe()` form
|
|
317
|
+
|
|
318
|
+
Every generated RPC and API callable has a `.safe()` sibling that returns `Result<T, ETyped>` instead of throwing. This eliminates `try/catch` for callers that prefer exhaustive narrowing via a discriminated union.
|
|
319
|
+
|
|
320
|
+
```typescript
|
|
321
|
+
const r = await api.users.GetUser.safe({ pathParams: { id: '123' } })
|
|
322
|
+
|
|
323
|
+
if (r.ok) {
|
|
324
|
+
console.log(r.value) // typed as GetUser.Response
|
|
325
|
+
} else {
|
|
326
|
+
switch (r.kind) {
|
|
327
|
+
case 'typed': // registry-dispatched error (instanceof ApiErrors.UseCaseError, etc.)
|
|
328
|
+
console.error(r.error.body)
|
|
329
|
+
break
|
|
330
|
+
case 'http': // ClientHttpError — non-2xx with unmatched or non-JSON body
|
|
331
|
+
console.error(`HTTP ${r.error.status}`)
|
|
332
|
+
break
|
|
333
|
+
case 'network': // ClientNetworkError
|
|
334
|
+
case 'timeout': // ClientTimeoutError
|
|
335
|
+
case 'aborted': // ClientAbortError
|
|
336
|
+
case 'parse': // ClientParseError — response body could not be parsed
|
|
337
|
+
case 'usage': // programming error (e.g., missing path param)
|
|
338
|
+
case 'unknown': // catch-all
|
|
339
|
+
console.error(r.error)
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
The underlying method is `ClientInstance.safeCall<TResponse, ETyped>(descriptor, options)` — all generated `.safe()` callables delegate to it. Streams keep the throwing form; `.safe()` is intentionally absent on stream procedures.
|
|
345
|
+
|
|
346
|
+
Custom error categories can be added to `r.kind`'s union via `ClientErrorMap` interface augmentation. See [`docs/client-error-handling.md`](./client-error-handling.md) for the full reference, including `Result<T, E>`, `ResultNoTyped<T>`, `FrameworkFailure`, `ErrorClassifier`, and `ClientErrorMap` augmentation patterns.
|
|
347
|
+
|
|
303
348
|
## Hooks
|
|
304
349
|
|
|
305
350
|
Hooks let you intercept requests and responses globally or per-procedure call. Global hooks apply to every call made through the client instance; per-procedure hooks run after global ones for a single invocation.
|
|
@@ -378,7 +423,7 @@ await generateClient({
|
|
|
378
423
|
By default, the generated output includes two additional files in the output directory:
|
|
379
424
|
|
|
380
425
|
- **`_types.ts`** — All client type definitions (`ClientInstance`, `TypedStream`, `ProcedureCallOptions`, hooks, adapters, descriptors)
|
|
381
|
-
- **`_client.ts`** — Full client runtime: `createClient`, `createFetchAdapter`, hook pipeline, and error classes (`
|
|
426
|
+
- **`_client.ts`** — Full client runtime: `createClient`, `createFetchAdapter`, hook pipeline, and error classes (`ClientHttpError`, `ClientNetworkError`, `ClientTimeoutError`, `ClientAbortError`, `ClientParseError`, `ClientPathParamError`, `ClientStreamError`)
|
|
382
427
|
|
|
383
428
|
All generated scope files and `index.ts` import from `./_types` instead of `ts-procedures/client`, so app consumers can import everything from the generated directory without needing `ts-procedures` as a runtime dependency. `ts-procedures` becomes a devDependency only.
|
|
384
429
|
|
|
@@ -396,7 +441,21 @@ import { createClient, createFetchAdapter } from 'ts-procedures/client'
|
|
|
396
441
|
|
|
397
442
|
```typescript
|
|
398
443
|
// Client Runtime
|
|
399
|
-
import {
|
|
444
|
+
import {
|
|
445
|
+
createClient,
|
|
446
|
+
createFetchAdapter,
|
|
447
|
+
dispatchTypedError,
|
|
448
|
+
defaultClassifyError,
|
|
449
|
+
// Error classes (7.0+)
|
|
450
|
+
ClientHttpError, // non-2xx HTTP response (renamed from ClientRequestError)
|
|
451
|
+
ClientNetworkError, // network-level failure (e.g., fetch TypeError)
|
|
452
|
+
ClientTimeoutError, // request timed out via AbortSignal.timeout
|
|
453
|
+
ClientAbortError, // request aborted by caller signal
|
|
454
|
+
ClientParseError, // response body could not be parsed as JSON
|
|
455
|
+
ClientPathParamError, // programming error: path param missing at call time
|
|
456
|
+
ClientStreamError, // stream-level transport error
|
|
457
|
+
} from 'ts-procedures/client'
|
|
458
|
+
|
|
400
459
|
import type {
|
|
401
460
|
ClientAdapter,
|
|
402
461
|
ClientHooks,
|
|
@@ -405,8 +464,19 @@ import type {
|
|
|
405
464
|
ErrorRegistry,
|
|
406
465
|
ErrorFactory,
|
|
407
466
|
ErrorResponseMeta,
|
|
467
|
+
// Safe-result types (7.0+)
|
|
468
|
+
Result, // Result<T, ETyped> — ok branch or failure branch
|
|
469
|
+
ResultNoTyped, // Result<T, never> — when no typed error registry is wired
|
|
470
|
+
FrameworkFailure, // The failure branch of Result (discriminated by `kind`)
|
|
471
|
+
ClientErrorMap, // Augmentable interface for custom error categories
|
|
472
|
+
// Classifier types (7.0+)
|
|
473
|
+
ErrorClassifier,
|
|
474
|
+
ClassifyErrorContext,
|
|
475
|
+
ClassifiedError,
|
|
408
476
|
} from 'ts-procedures/client'
|
|
409
477
|
|
|
410
478
|
// Code Generation
|
|
411
479
|
import { generateClient } from 'ts-procedures/codegen'
|
|
412
480
|
```
|
|
481
|
+
|
|
482
|
+
> **Migration note:** `ClientRequestError` was renamed to `ClientHttpError` in 7.0.0. The old name is re-exported as a deprecated alias for one minor cycle. Update imports when possible.
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
# Client Error Handling
|
|
2
|
+
|
|
3
|
+
Canonical reference for downstream developers using ts-procedures generated clients to handle errors.
|
|
4
|
+
|
|
5
|
+
## 1. Two Forms — Pick Either, Mix as Needed
|
|
6
|
+
|
|
7
|
+
Every generated RPC and API callable supports two equivalent call styles.
|
|
8
|
+
|
|
9
|
+
**Throwing form** (the canonical form): the call returns the response directly or throws a typed error class.
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
const response = await client.users.GetUser({ userId })
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
**`.safe()` form**: the call always resolves (never rejects) and returns a discriminated `Result<T, E>`. Useful when you want exhaustive failure handling with TypeScript narrowing inside an exhaustive `switch`.
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
const r = await client.users.GetUser.safe({ userId })
|
|
19
|
+
if (r.ok) {
|
|
20
|
+
// r.value is typed as the response
|
|
21
|
+
} else {
|
|
22
|
+
// r.kind narrows the failure category
|
|
23
|
+
}
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Both forms fire the same hooks, respect the same defaults, and go through the same error registry. You can mix them freely — per call site, use whichever reads most naturally.
|
|
27
|
+
|
|
28
|
+
> Note: `.safe()` is only available on RPC and API callables. Stream callables keep the throwing form — their failure surface (mid-stream errors, backpressure, early disconnection) does not fit cleanly into `Result<T, E>`.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## 2. Framework Error Class Taxonomy
|
|
33
|
+
|
|
34
|
+
All framework error classes are exported from `ts-procedures/client` (or from `./_types` in self-contained generated clients).
|
|
35
|
+
|
|
36
|
+
| Class | When it is thrown | Notable properties |
|
|
37
|
+
|---|---|---|
|
|
38
|
+
| `ClientHttpError` | Adapter returned a non-2xx response and no error registry entry matched | `status`, `headers`, `body`, `procedureName`, `scope`, `cause` |
|
|
39
|
+
| `ClientNetworkError` | Adapter threw a `TypeError` (e.g., DNS failure, connection refused) | `procedureName`, `scope`, `cause` |
|
|
40
|
+
| `ClientTimeoutError` | The request was aborted because the timeout signal fired | `procedureName`, `scope`, `timeoutMs`, `cause` |
|
|
41
|
+
| `ClientAbortError` | The request was aborted because a user-supplied signal fired | `procedureName`, `scope`, `reason`, `cause` |
|
|
42
|
+
| `ClientPathParamError` | A required path parameter was missing when building the request | `cause` |
|
|
43
|
+
| `ClientParseError` | Reserved for adapter authors who want stricter response-body parsing | `procedureName`, `scope`, `cause` |
|
|
44
|
+
| `ClientStreamError` | A mid-stream SSE error event was received | `procedureName`, `scope`, `cause` |
|
|
45
|
+
|
|
46
|
+
Every class carries `cause` to surface the underlying platform error — typically a `TypeError` or `DOMException` — when one exists.
|
|
47
|
+
|
|
48
|
+
`ClientRequestError` is a deprecated alias for `ClientHttpError`. It is retained for one minor release after 7.0.0 and will be removed in a subsequent minor. See the [migration guide](#8-migration-6x--7x) below.
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## 3. Throwing Form: Narrowing Pattern
|
|
53
|
+
|
|
54
|
+
The throwing form is the most common pattern. Catch the error and narrow by class:
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
import { ClientHttpError, ClientTimeoutError, ClientNetworkError, ClientAbortError } from 'ts-procedures/client'
|
|
58
|
+
// ApiErrors is exported from your generated index, e.g.:
|
|
59
|
+
// import { ApiErrors } from './generated'
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
const response = await client.records.DownloadRecord(
|
|
63
|
+
{ body: { recordId } },
|
|
64
|
+
{ timeout: 60_000 },
|
|
65
|
+
)
|
|
66
|
+
if (response.pdfBase64) downloadBase64AsPdf(response.pdfBase64)
|
|
67
|
+
} catch (err) {
|
|
68
|
+
if (err instanceof ApiErrors.UseCaseError) {
|
|
69
|
+
// Typed error declared on the route — strongly typed body
|
|
70
|
+
Alerts.error(err.body.message)
|
|
71
|
+
} else if (err instanceof ClientHttpError) {
|
|
72
|
+
Alerts.error(`Server returned ${err.status}`)
|
|
73
|
+
} else if (err instanceof ClientTimeoutError) {
|
|
74
|
+
Alerts.error('Request timed out')
|
|
75
|
+
} else if (err instanceof ClientNetworkError) {
|
|
76
|
+
Alerts.error('Network error — check your connection')
|
|
77
|
+
} else if (err instanceof ClientAbortError) {
|
|
78
|
+
// User cancelled — silent
|
|
79
|
+
} else {
|
|
80
|
+
throw err // Programmer or framework bug — fail loud
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`ApiErrors.UseCaseError` is illustrative. The actual namespace is `${ServiceName}Errors` based on your `--service-name` codegen option (default: `ApiErrors`). Each error class name matches the key you declared in your server-side error taxonomy.
|
|
86
|
+
|
|
87
|
+
The `else { throw err }` rethrow at the end is intentional: any error that slips past all your `instanceof` checks is a bug — do not swallow it.
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## 4. `.safe()` Form: Exhaustive Narrowing
|
|
92
|
+
|
|
93
|
+
The same operation rewritten with `.safe()`. The call never rejects. `r.ok` is the top-level gate; `r.kind` narrows the failure:
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
import { ApiErrors } from './generated'
|
|
97
|
+
|
|
98
|
+
const r = await client.records.DownloadRecord.safe(
|
|
99
|
+
{ body: { recordId } },
|
|
100
|
+
{ timeout: 60_000 },
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
if (r.ok) {
|
|
104
|
+
if (r.value.pdfBase64) downloadBase64AsPdf(r.value.pdfBase64)
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
switch (r.kind) {
|
|
109
|
+
case 'typed':
|
|
110
|
+
if (r.error instanceof ApiErrors.UseCaseError) Alerts.error(r.error.body.message)
|
|
111
|
+
break
|
|
112
|
+
case 'http':
|
|
113
|
+
Alerts.error(`Server returned ${r.error.status}`)
|
|
114
|
+
break
|
|
115
|
+
case 'network':
|
|
116
|
+
Alerts.error('Network error')
|
|
117
|
+
break
|
|
118
|
+
case 'timeout':
|
|
119
|
+
Alerts.error('Request timed out')
|
|
120
|
+
break
|
|
121
|
+
case 'aborted':
|
|
122
|
+
break // User cancelled — silent
|
|
123
|
+
case 'parse':
|
|
124
|
+
case 'usage':
|
|
125
|
+
throw r.error // Programmer or framework bug — fail loud
|
|
126
|
+
case 'unknown':
|
|
127
|
+
Alerts.error('Unknown error')
|
|
128
|
+
console.error(r.error)
|
|
129
|
+
break
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
The `Result<T, E>` type is:
|
|
134
|
+
|
|
135
|
+
```ts
|
|
136
|
+
type Result<T, ETyped> =
|
|
137
|
+
| { ok: true; value: T }
|
|
138
|
+
| { ok: false; kind: 'typed'; error: ETyped } // route-declared, registry-dispatched
|
|
139
|
+
| { ok: false; kind: 'http'; error: ClientHttpError }
|
|
140
|
+
| { ok: false; kind: 'network'; error: ClientNetworkError }
|
|
141
|
+
| { ok: false; kind: 'timeout'; error: ClientTimeoutError }
|
|
142
|
+
| { ok: false; kind: 'aborted'; error: ClientAbortError }
|
|
143
|
+
| { ok: false; kind: 'parse'; error: ClientParseError }
|
|
144
|
+
| { ok: false; kind: 'usage'; error: ClientPathParamError }
|
|
145
|
+
| { ok: false; kind: 'unknown'; error: unknown }
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
When a route declares no typed errors, the generated callable returns `ResultNoTyped<T>` — the `kind: 'typed'` arm is omitted entirely so IDE hovers stay clean.
|
|
149
|
+
|
|
150
|
+
---
|
|
151
|
+
|
|
152
|
+
## 5. The Three Failure-Source Paths
|
|
153
|
+
|
|
154
|
+
Understanding where errors originate helps you know what to expect at each stage:
|
|
155
|
+
|
|
156
|
+
| # | Stage | Source of failure | Resulting kind(s) |
|
|
157
|
+
|---|---|---|---|
|
|
158
|
+
| 1 | **Pre-adapter** | `buildAdapterRequest` throws synchronously (e.g. missing path param) | `'usage'` (`ClientPathParamError`) — bypasses the classifier and `onError` hook |
|
|
159
|
+
| 2 | **Adapter throws** | `adapter.request()` rejects (network failure, abort, or custom) | `'network'` / `'timeout'` / `'aborted'` / custom kinds / `'unknown'` |
|
|
160
|
+
| 3 | **Adapter returns non-2xx** | `response.status < 200` or `>= 300` | `'typed'` (registry match) or `'http'` (no match → `ClientHttpError`) |
|
|
161
|
+
|
|
162
|
+
Stage 1 (`'usage'`) is always a programming error — a required path parameter is absent. These bypass `onError` and the error classifier because the request was never sent.
|
|
163
|
+
|
|
164
|
+
Stage 2 errors go through `adapter.classifyError` (if present) then `defaultClassifyError`. Anything the classifier does not recognise lands as `'unknown'`.
|
|
165
|
+
|
|
166
|
+
Stage 3: when a non-2xx response body has a `name` field that matches an entry in the error registry, the client constructs and throws the matching typed error class. When no entry matches, the client throws `ClientHttpError`.
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
## 6. Custom Error Categories — `ClientErrorMap` Augmentation
|
|
171
|
+
|
|
172
|
+
The `kind` discriminant set is open for extension via TypeScript declaration merging. This lets you teach the type system about custom error kinds produced by your adapter:
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
// Declare the new kind once in your app — usually alongside your adapter setup.
|
|
176
|
+
class RateLimitError extends Error {
|
|
177
|
+
constructor(public retryAfter: number) {
|
|
178
|
+
super('rate limited')
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// With the standard (non-self-contained) client:
|
|
183
|
+
declare module 'ts-procedures/client' {
|
|
184
|
+
interface ClientErrorMap {
|
|
185
|
+
rateLimited: RateLimitError
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
For self-contained generated clients, the augmentation target shifts to the bundled types file:
|
|
191
|
+
|
|
192
|
+
```ts
|
|
193
|
+
// With a self-contained (code-generated) client:
|
|
194
|
+
declare module './generated/_types' {
|
|
195
|
+
interface ClientErrorMap {
|
|
196
|
+
rateLimited: RateLimitError
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
This is the same pattern as `RequestMeta` augmentation — if you have used that, this is identical.
|
|
202
|
+
|
|
203
|
+
After augmentation, `Result`'s union expands automatically. The new `kind` is exhaustively narrowable in every `.safe()` switch:
|
|
204
|
+
|
|
205
|
+
```ts
|
|
206
|
+
const r = await client.records.DownloadRecord.safe({ body: { recordId } })
|
|
207
|
+
if (!r.ok && r.kind === 'rateLimited') {
|
|
208
|
+
Alerts.error(`Retry in ${r.error.retryAfter}s`)
|
|
209
|
+
}
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
**`'typed'` is reserved.** Do not add it to your `ClientErrorMap` augmentation — it is used internally for route-declared errors dispatched via the codegen `errors` key. Adding it produces a TypeScript error about overlapping discriminants.
|
|
213
|
+
|
|
214
|
+
---
|
|
215
|
+
|
|
216
|
+
## 7. Adapter-Side Classifier Composition
|
|
217
|
+
|
|
218
|
+
Custom error kinds only flow through the `Result` union if the adapter's classifier produces them. Configure the classifier on `createFetchAdapter`:
|
|
219
|
+
|
|
220
|
+
```ts
|
|
221
|
+
import { createFetchAdapter, defaultClassifyError } from 'ts-procedures/client'
|
|
222
|
+
|
|
223
|
+
const adapter = createFetchAdapter({
|
|
224
|
+
classifyError: (raw, ctx) => {
|
|
225
|
+
if (raw instanceof MyRateLimitError) {
|
|
226
|
+
return { kind: 'rateLimited', error: raw }
|
|
227
|
+
}
|
|
228
|
+
// Fall through to the built-in classifier for TypeError / DOMException / etc.
|
|
229
|
+
return defaultClassifyError(raw, ctx)
|
|
230
|
+
},
|
|
231
|
+
})
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
`defaultClassifyError` handles:
|
|
235
|
+
- `TypeError` → `ClientNetworkError` (`kind: 'network'`)
|
|
236
|
+
- `DOMException { name: 'AbortError' }` + timeout signal aborted → `ClientTimeoutError` (`kind: 'timeout'`)
|
|
237
|
+
- `DOMException { name: 'AbortError' }` + user signal aborted → `ClientAbortError` (`kind: 'aborted'`)
|
|
238
|
+
|
|
239
|
+
Composing with `defaultClassifyError` at the end is the recommended pattern. Adapter authors who want to completely replace the default can omit the fallthrough — anything the classifier returns `null` for lands as `kind: 'unknown'`.
|
|
240
|
+
|
|
241
|
+
The classifier is called only when the adapter throws (Stage 2 above). Non-2xx responses go through the error registry path (Stage 3), not the classifier.
|
|
242
|
+
|
|
243
|
+
---
|
|
244
|
+
|
|
245
|
+
## 8. Migration: 6.x → 7.x
|
|
246
|
+
|
|
247
|
+
Three breaking changes in 7.0.0 affect error handling.
|
|
248
|
+
|
|
249
|
+
### 8a. `ClientRequestError` Renamed to `ClientHttpError`
|
|
250
|
+
|
|
251
|
+
`ClientRequestError` is a deprecated alias for `ClientHttpError`, retained for one minor release after 7.0.0.
|
|
252
|
+
|
|
253
|
+
**Action**: rename your imports from `ClientRequestError` to `ClientHttpError`. The `instanceof ClientRequestError` guard continues to work until the alias is removed.
|
|
254
|
+
|
|
255
|
+
```ts
|
|
256
|
+
// Before
|
|
257
|
+
import { ClientRequestError } from 'ts-procedures/client'
|
|
258
|
+
if (err instanceof ClientRequestError) { ... }
|
|
259
|
+
|
|
260
|
+
// After
|
|
261
|
+
import { ClientHttpError } from 'ts-procedures/client'
|
|
262
|
+
if (err instanceof ClientHttpError) { ... }
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
### 8b. Raw `DOMException` / `TypeError` No Longer Reach Consumer Catch Blocks
|
|
266
|
+
|
|
267
|
+
Before 7.0.0, the fetch adapter let platform errors propagate directly. After 7.0.0, all thrown errors from the adapter are normalized through the classifier before reaching your code.
|
|
268
|
+
|
|
269
|
+
```ts
|
|
270
|
+
// Before 6.x — catching raw platform errors
|
|
271
|
+
try {
|
|
272
|
+
await client.users.GetUser(params)
|
|
273
|
+
} catch (e) {
|
|
274
|
+
if (e instanceof DOMException && e.name === 'AbortError') {
|
|
275
|
+
// handle abort
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// After 7.x — catching framework error classes
|
|
280
|
+
try {
|
|
281
|
+
await client.users.GetUser(params)
|
|
282
|
+
} catch (e) {
|
|
283
|
+
if (e instanceof ClientTimeoutError) {
|
|
284
|
+
// timed out
|
|
285
|
+
} else if (e instanceof ClientAbortError) {
|
|
286
|
+
// user-cancelled
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
The original platform error is preserved on `error.cause` if you need to inspect it.
|
|
292
|
+
|
|
293
|
+
### 8c. `onError` Hook Receives the Normalized Framework Error
|
|
294
|
+
|
|
295
|
+
Before 7.0.0, the `onError` hook saw the raw `DOMException` or `TypeError`. After 7.0.0, the hook sees the normalized framework class (`ClientNetworkError`, `ClientTimeoutError`, etc.), with the original platform error on `.cause`.
|
|
296
|
+
|
|
297
|
+
**Action**: update any hook logic that inspected the raw error shape. Most consumers only call `console.error(error)` or log `error.message` and are unaffected.
|
|
298
|
+
|
|
299
|
+
---
|
|
300
|
+
|
|
301
|
+
## 9. FAQ — `.safe()` and Retry Loops
|
|
302
|
+
|
|
303
|
+
**Q: My retry loop using `.safe()` triggers `onError` once per attempt. Is that intentional?**
|
|
304
|
+
|
|
305
|
+
Yes. `onError` fires on every failure regardless of which call style you use. Telemetry consumers want to see all attempts uniformly.
|
|
306
|
+
|
|
307
|
+
To suppress logging on a retry loop, you have two options:
|
|
308
|
+
|
|
309
|
+
**Option 1 — per-call `onError` no-op:**
|
|
310
|
+
|
|
311
|
+
```ts
|
|
312
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
313
|
+
const r = await client.users.GetUser.safe(params, {
|
|
314
|
+
onError: () => {}, // suppress onError for this call
|
|
315
|
+
})
|
|
316
|
+
if (r.ok) return r.value
|
|
317
|
+
// inspect r.kind to decide whether to retry
|
|
318
|
+
}
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
**Option 2 — flag in `meta`, check in the global `onError`:**
|
|
322
|
+
|
|
323
|
+
```ts
|
|
324
|
+
// Augment RequestMeta once in your app:
|
|
325
|
+
declare module 'ts-procedures/client' {
|
|
326
|
+
interface RequestMeta { isRetry?: boolean }
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// In createClient setup:
|
|
330
|
+
const client = createApiClient({
|
|
331
|
+
basePath: 'https://api.example.com',
|
|
332
|
+
hooks: {
|
|
333
|
+
onError: ({ request, error }) => {
|
|
334
|
+
if (request.meta?.isRetry) return // skip telemetry for retries
|
|
335
|
+
logger.error(error)
|
|
336
|
+
},
|
|
337
|
+
},
|
|
338
|
+
})
|
|
339
|
+
|
|
340
|
+
// At call sites:
|
|
341
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
342
|
+
const r = await client.users.GetUser.safe(params, {
|
|
343
|
+
meta: { isRetry: attempt > 0 },
|
|
344
|
+
})
|
|
345
|
+
if (r.ok) return r.value
|
|
346
|
+
}
|
|
347
|
+
```
|
|
348
|
+
|
|
349
|
+
A framework-level "skip `onError` for `.safe()`" toggle was deliberately deferred to a future minor release. The workarounds above are sufficient for the common case.
|
|
350
|
+
|
|
351
|
+
---
|
|
352
|
+
|
|
353
|
+
## 10. Reference
|
|
354
|
+
|
|
355
|
+
- Design spec: [`docs/superpowers/specs/2026-04-29-safe-result-api-design.md`](./superpowers/specs/2026-04-29-safe-result-api-design.md)
|
|
356
|
+
- Client types and interfaces: `CLAUDE.md` — "Key Files" → `src/client/`
|
|
357
|
+
- Agent config patterns and anti-patterns: `agent_config/claude-code/skills/ts-procedures/patterns.md` and `anti-patterns.md`
|