ts-procedures 5.16.0 → 6.0.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.
- package/README.md +2 -0
- package/agent_config/claude-code/agents/ts-procedures-architect.md +13 -6
- package/agent_config/claude-code/skills/ts-procedures/SKILL.md +26 -4
- package/agent_config/claude-code/skills/ts-procedures/anti-patterns.md +87 -19
- package/agent_config/claude-code/skills/ts-procedures/api-reference.md +162 -16
- package/agent_config/claude-code/skills/ts-procedures/patterns.md +179 -16
- package/agent_config/claude-code/skills/ts-procedures-review/SKILL.md +1 -1
- package/agent_config/claude-code/skills/ts-procedures-review/checklist.md +20 -12
- package/agent_config/claude-code/skills/ts-procedures-scaffold/SKILL.md +2 -1
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/client.md +22 -15
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/express-rpc.md +20 -17
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono-api.md +20 -16
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono-rpc.md +20 -17
- package/agent_config/claude-code/skills/ts-procedures-scaffold/templates/hono-stream.md +16 -3
- package/agent_config/copilot/copilot-instructions.md +78 -12
- package/agent_config/cursor/cursorrules +78 -12
- package/build/client/call.d.ts +2 -1
- package/build/client/call.js +9 -1
- package/build/client/call.js.map +1 -1
- package/build/client/error-dispatch.d.ts +13 -0
- package/build/client/error-dispatch.js +26 -0
- package/build/client/error-dispatch.js.map +1 -0
- package/build/client/error-dispatch.test.d.ts +1 -0
- package/build/client/error-dispatch.test.js +56 -0
- package/build/client/error-dispatch.test.js.map +1 -0
- package/build/client/fetch-adapter.js +10 -4
- package/build/client/fetch-adapter.js.map +1 -1
- package/build/client/index.d.ts +2 -1
- package/build/client/index.js +5 -1
- package/build/client/index.js.map +1 -1
- package/build/client/stream.d.ts +2 -1
- package/build/client/stream.js +13 -3
- package/build/client/stream.js.map +1 -1
- package/build/client/typed-error-dispatch.test.d.ts +1 -0
- package/build/client/typed-error-dispatch.test.js +168 -0
- package/build/client/typed-error-dispatch.test.js.map +1 -0
- package/build/client/types.d.ts +37 -0
- package/build/codegen/e2e.test.js +9 -4
- package/build/codegen/e2e.test.js.map +1 -1
- package/build/codegen/emit-client-runtime.js +4 -0
- package/build/codegen/emit-client-runtime.js.map +1 -1
- package/build/codegen/emit-errors.d.ts +17 -6
- package/build/codegen/emit-errors.integration.test.d.ts +1 -0
- package/build/codegen/emit-errors.integration.test.js +162 -0
- package/build/codegen/emit-errors.integration.test.js.map +1 -0
- package/build/codegen/emit-errors.js +50 -39
- package/build/codegen/emit-errors.js.map +1 -1
- package/build/codegen/emit-errors.test.js +75 -78
- package/build/codegen/emit-errors.test.js.map +1 -1
- package/build/codegen/emit-index.d.ts +7 -0
- package/build/codegen/emit-index.js +26 -4
- package/build/codegen/emit-index.js.map +1 -1
- package/build/codegen/emit-index.test.js +55 -23
- package/build/codegen/emit-index.test.js.map +1 -1
- package/build/codegen/emit-scope.d.ts +8 -0
- package/build/codegen/emit-scope.js +82 -7
- package/build/codegen/emit-scope.js.map +1 -1
- package/build/codegen/pipeline.js +22 -2
- package/build/codegen/pipeline.js.map +1 -1
- package/build/implementations/http/doc-registry.d.ts +17 -1
- package/build/implementations/http/doc-registry.js +47 -79
- package/build/implementations/http/doc-registry.js.map +1 -1
- package/build/implementations/http/doc-registry.test.js +149 -16
- package/build/implementations/http/doc-registry.test.js.map +1 -1
- package/build/implementations/http/error-taxonomy.d.ts +249 -0
- package/build/implementations/http/error-taxonomy.js +252 -0
- package/build/implementations/http/error-taxonomy.js.map +1 -0
- package/build/implementations/http/error-taxonomy.test.d.ts +1 -0
- package/build/implementations/http/error-taxonomy.test.js +399 -0
- package/build/implementations/http/error-taxonomy.test.js.map +1 -0
- package/build/implementations/http/express-rpc/error-taxonomy.test.d.ts +1 -0
- package/build/implementations/http/express-rpc/error-taxonomy.test.js +83 -0
- package/build/implementations/http/express-rpc/error-taxonomy.test.js.map +1 -0
- package/build/implementations/http/express-rpc/index.d.ts +39 -8
- package/build/implementations/http/express-rpc/index.js +39 -8
- package/build/implementations/http/express-rpc/index.js.map +1 -1
- package/build/implementations/http/hono-api/error-taxonomy.test.d.ts +1 -0
- package/build/implementations/http/hono-api/error-taxonomy.test.js +137 -0
- package/build/implementations/http/hono-api/error-taxonomy.test.js.map +1 -0
- package/build/implementations/http/hono-api/index.d.ts +38 -1
- package/build/implementations/http/hono-api/index.js +32 -0
- package/build/implementations/http/hono-api/index.js.map +1 -1
- package/build/implementations/http/hono-rpc/error-taxonomy.test.d.ts +1 -0
- package/build/implementations/http/hono-rpc/error-taxonomy.test.js +64 -0
- package/build/implementations/http/hono-rpc/error-taxonomy.test.js.map +1 -0
- package/build/implementations/http/hono-rpc/index.d.ts +34 -7
- package/build/implementations/http/hono-rpc/index.js +31 -4
- package/build/implementations/http/hono-rpc/index.js.map +1 -1
- package/build/implementations/http/hono-stream/error-taxonomy.test.d.ts +1 -0
- package/build/implementations/http/hono-stream/error-taxonomy.test.js +87 -0
- package/build/implementations/http/hono-stream/error-taxonomy.test.js.map +1 -0
- package/build/implementations/http/hono-stream/index.d.ts +40 -3
- package/build/implementations/http/hono-stream/index.js +37 -10
- package/build/implementations/http/hono-stream/index.js.map +1 -1
- package/build/implementations/http/hono-stream/index.test.js +45 -18
- package/build/implementations/http/hono-stream/index.test.js.map +1 -1
- package/build/implementations/http/on-request-error.test.d.ts +1 -0
- package/build/implementations/http/on-request-error.test.js +173 -0
- package/build/implementations/http/on-request-error.test.js.map +1 -0
- package/build/implementations/http/route-errors.test.d.ts +1 -0
- package/build/implementations/http/route-errors.test.js +139 -0
- package/build/implementations/http/route-errors.test.js.map +1 -0
- package/build/implementations/types.d.ts +43 -3
- package/docs/client-and-codegen.md +105 -12
- package/docs/core.md +14 -5
- package/docs/http-integrations.md +138 -5
- package/docs/streaming.md +3 -1
- package/docs/superpowers/plans/2026-04-24-doc-registry-simplification.md +886 -0
- package/package.json +7 -2
- package/src/client/call.ts +10 -1
- package/src/client/error-dispatch.test.ts +72 -0
- package/src/client/error-dispatch.ts +27 -0
- package/src/client/fetch-adapter.ts +11 -5
- package/src/client/index.ts +9 -0
- package/src/client/stream.ts +14 -3
- package/src/client/typed-error-dispatch.test.ts +211 -0
- package/src/client/types.ts +42 -0
- package/src/codegen/e2e.test.ts +9 -4
- package/src/codegen/emit-client-runtime.ts +4 -0
- package/src/codegen/emit-errors.integration.test.ts +183 -0
- package/src/codegen/emit-errors.test.ts +91 -87
- package/src/codegen/emit-errors.ts +123 -41
- package/src/codegen/emit-index.test.ts +68 -24
- package/src/codegen/emit-index.ts +66 -4
- package/src/codegen/emit-scope.ts +124 -7
- package/src/codegen/pipeline.ts +25 -2
- package/src/implementations/http/README.md +21 -7
- package/src/implementations/http/doc-registry.test.ts +164 -16
- package/src/implementations/http/doc-registry.ts +58 -82
- package/src/implementations/http/error-taxonomy.test.ts +438 -0
- package/src/implementations/http/error-taxonomy.ts +361 -0
- package/src/implementations/http/express-rpc/README.md +23 -24
- package/src/implementations/http/express-rpc/error-taxonomy.test.ts +103 -0
- package/src/implementations/http/express-rpc/index.ts +75 -14
- package/src/implementations/http/hono-api/README.md +284 -0
- package/src/implementations/http/hono-api/error-taxonomy.test.ts +179 -0
- package/src/implementations/http/hono-api/index.ts +76 -1
- package/src/implementations/http/hono-rpc/README.md +20 -21
- package/src/implementations/http/hono-rpc/error-taxonomy.test.ts +82 -0
- package/src/implementations/http/hono-rpc/index.ts +65 -9
- package/src/implementations/http/hono-stream/README.md +44 -25
- package/src/implementations/http/hono-stream/error-taxonomy.test.ts +98 -0
- package/src/implementations/http/hono-stream/index.test.ts +54 -18
- package/src/implementations/http/hono-stream/index.ts +83 -13
- package/src/implementations/http/on-request-error.test.ts +201 -0
- package/src/implementations/http/route-errors.test.ts +176 -0
- package/src/implementations/types.ts +43 -3
package/README.md
CHANGED
|
@@ -50,6 +50,8 @@ const user2 = await procedure({}, { userId: '456' })
|
|
|
50
50
|
|
|
51
51
|
- **[Client Code Generation](docs/client-and-codegen.md)** — Generate type-safe client SDKs from your server's `DocRegistry`. CLI and programmatic API, adapters, hooks, streaming support, and self-contained mode.
|
|
52
52
|
|
|
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
|
+
|
|
53
55
|
- **[AI Agent Setup](docs/ai-agent-setup.md)** — Built-in configuration for Claude Code, Cursor, and GitHub Copilot. Auto-updates on `npm install`.
|
|
54
56
|
|
|
55
57
|
Full documentation is available on [GitHub](https://github.com/thermsio/ts-procedures).
|
|
@@ -37,7 +37,10 @@ When asked to plan an API or procedure set:
|
|
|
37
37
|
- AJV is configured with `allErrors: true`, `coerceTypes: true`, `removeAdditional: true`.
|
|
38
38
|
- `schema.params` and `schema.input` are mutually exclusive — defining both throws `ProcedureRegistrationError`.
|
|
39
39
|
- Path param names in route template (`:id`) must match `schema.input.pathParams` property names.
|
|
40
|
-
- Use `DocRegistry` to compose route docs from multiple builders — never manually wire `/docs` endpoints.
|
|
40
|
+
- Use `DocRegistry` to compose route docs from multiple builders — never manually wire `/docs` endpoints. Pass your taxonomy directly: `new DocRegistry({ errors: appErrors })` — framework defaults are auto-merged and deduped. For errors outside your taxonomy (middleware, infrastructure, doc-only), chain `.documentError(...docs)`.
|
|
41
|
+
- Two first-class peer error-handling modes: **declarative taxonomy** (`defineErrorTaxonomy` + `errors` config) OR **imperative callback** (`onError`). Neither is deprecated. Pick the taxonomy for structured apps with typed client dispatch; pick `onError` for simple apps or full response control. Mixing both is allowed — the taxonomy handles what it covers, `onError` handles the tail. The anti-pattern is `instanceof` ladders inside `onError` (see anti-pattern #20 in the skill reference) — that's exactly what the taxonomy expresses declaratively.
|
|
42
|
+
- Per-route `errors: ['UseCaseError', ...]` narrows typed errors on the generated client. Declare via `APIConfig<keyof typeof appErrors & string>` / `RPCConfig<keyof typeof appErrors & string>` for compile-time typo protection.
|
|
43
|
+
- Generated `_errors.ts` emits real runtime classes extending `${Service}ProcedureError` — consumers catch with `instanceof ${Service}Errors.${Name}` and access `err.body`, `err.status`, `err.procedureName`, `err.scope`. Use the generated `create${Service}Client(config)` factory to wire the error registry automatically.
|
|
41
44
|
|
|
42
45
|
## Context Design Patterns
|
|
43
46
|
|
|
@@ -120,11 +123,15 @@ type AuthContext = { userId: string; requestId: string; db: Database }
|
|
|
120
123
|
- POST /api/users (HonoAPIAppBuilder, 201)
|
|
121
124
|
- DELETE /api/users/:id (HonoAPIAppBuilder, 204)
|
|
122
125
|
|
|
123
|
-
### Error Handling
|
|
124
|
-
-
|
|
125
|
-
-
|
|
126
|
-
-
|
|
127
|
-
-
|
|
126
|
+
### Error Handling (pick a mode, optionally combine)
|
|
127
|
+
- **Declarative mode (recommended for structured apps)**: `defineErrorTaxonomy({ AuthError: {class, 401}, NotFoundError: {class, 404}, ... })` wired via `errors` config. Drives typed client dispatch + DocEnvelope.
|
|
128
|
+
- **Imperative mode (simple apps or full response control)**: `onError: (procedure, c|req/res, err) => Response|void` — first-class peer.
|
|
129
|
+
- `unknownError` — fallback serializer for errors the taxonomy doesn't cover (pairs with declarative mode).
|
|
130
|
+
- `onRequestError` — cross-cutting observer for logging/tracing/metrics. Fires for every error, before dispatch.
|
|
131
|
+
- Input validation is automatic (default taxonomy: `ProcedureValidationError` → 400).
|
|
132
|
+
- Business errors: throw typed class instances — registered taxonomy classes auto-serialize, or handle in `onError`.
|
|
133
|
+
- Per-route: declare `errors: ['AuthError', ...]` on each route config so the generated client narrows `catch` types.
|
|
134
|
+
- Stream pre-errors: both peer modes apply. Mid-stream errors: `onMidStreamError` → yield error event, close stream.
|
|
128
135
|
```
|
|
129
136
|
|
|
130
137
|
## Next Steps
|
|
@@ -76,9 +76,29 @@ AJV config: `allErrors: true`, `coerceTypes: true`, `removeAdditional: true`
|
|
|
76
76
|
| `ProcedureValidationError` | Schema params validation failure | `procedureName`, `errors[]` (AJV errors) |
|
|
77
77
|
| `ProcedureYieldValidationError` | Stream yield validation failure | `procedureName`, `errors[]` (AJV errors) |
|
|
78
78
|
| `ProcedureRegistrationError` | Invalid schema at registration time | `procedureName`, `message` |
|
|
79
|
+
| `${Service}ProcedureError` (generated, client) | Base class for all generated client error classes | `status`, `procedureName`, `scope`, `body` |
|
|
79
80
|
|
|
80
81
|
All errors include `definedAt` (file:line:column) and enhanced stack traces pointing to the procedure definition location.
|
|
81
82
|
|
|
83
|
+
## Error Taxonomy (HTTP builders)
|
|
84
|
+
|
|
85
|
+
Declare error classes once with `defineErrorTaxonomy` (from `ts-procedures/http-errors`) and pass to any HTTP builder via the `errors` option. Handlers `throw` their classes; the builder serializes to the configured status + body. This is the declarative peer of the imperative `onError` callback — both modes are first-class.
|
|
86
|
+
|
|
87
|
+
```typescript
|
|
88
|
+
import { defineErrorTaxonomy } from 'ts-procedures/http-errors'
|
|
89
|
+
|
|
90
|
+
const appErrors = defineErrorTaxonomy({
|
|
91
|
+
AuthError: { class: AuthError, statusCode: 401 },
|
|
92
|
+
UseCaseError: { class: UseCaseError, statusCode: 422, toResponse: (e) => ({ message: e.externalMsg }) },
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
new HonoAPIAppBuilder({ errors: appErrors, unknownError: { toResponse: () => ({ error: '...' }) } })
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Per-route narrowing: `APIConfig<keyof typeof appErrors & string>` / `RPCConfig<keyof typeof appErrors & string>` with a `errors: [...]` array on the config. Generated `_errors.ts` emits runtime classes — clients catch with `instanceof ${Service}Errors.${Name}` when using `create${Service}Client`.
|
|
99
|
+
|
|
100
|
+
Full contract: `docs/http-integrations.md § Error Handling` (canonical) and `api-reference.md § Error Taxonomy API`.
|
|
101
|
+
|
|
82
102
|
## HTTP Implementations
|
|
83
103
|
|
|
84
104
|
| Builder | Import | Transport |
|
|
@@ -112,10 +132,11 @@ const app = new ExpressRPCAppBuilder({ pathPrefix: '/api' })
|
|
|
112
132
|
| `onRequestStart` | Before context resolution |
|
|
113
133
|
| `onRequestEnd` | After response sent |
|
|
114
134
|
| `onSuccess` | After successful handler execution |
|
|
115
|
-
| `onError` |
|
|
135
|
+
| `onError` | Imperative error callback — first-class peer of the declarative `errors` taxonomy |
|
|
116
136
|
| `onStreamStart` | Before first yield (HonoStreamAppBuilder) |
|
|
117
137
|
| `onStreamEnd` | After stream closes (HonoStreamAppBuilder) |
|
|
118
|
-
| `
|
|
138
|
+
| `onError` (HonoStream) | Imperative pre-stream error callback — peer of `errors` taxonomy |
|
|
139
|
+
| `onRequestError` | Cross-cutting observer — fires for every caught error before dispatch |
|
|
119
140
|
| `onMidStreamError` | Error during streaming (return data to yield as final event) |
|
|
120
141
|
|
|
121
142
|
## Decision Framework
|
|
@@ -150,8 +171,9 @@ The npm package ships user-facing documentation with narrative explanations and
|
|
|
150
171
|
|------|--------|
|
|
151
172
|
| `docs/core.md` | Procedures factory, Create, CreateStream, schema.input, error handling |
|
|
152
173
|
| `docs/streaming.md` | Streaming procedures, AbortSignal, SSE patterns |
|
|
153
|
-
| `docs/http-integrations.md` | Express RPC, Hono RPC/Stream/API builders, DocRegistry |
|
|
154
|
-
| `docs/client-and-codegen.md` | Client code generation, createClient, per-call options
|
|
174
|
+
| `docs/http-integrations.md` | Express RPC, Hono RPC/Stream/API builders, **error taxonomy (canonical)**, DocRegistry (unified constructor, `.documentError()`) |
|
|
175
|
+
| `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 |
|
|
176
|
+
| `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. |
|
|
155
177
|
|
|
156
178
|
## Workflow
|
|
157
179
|
|
|
@@ -404,19 +404,21 @@ new HonoStreamAppBuilder({
|
|
|
404
404
|
})
|
|
405
405
|
```
|
|
406
406
|
|
|
407
|
-
**Fix:** Use `onPreStreamError`
|
|
407
|
+
**Fix:** Use either peer mode for pre-stream errors — the declarative taxonomy (`errors` + `unknownError`) or the imperative `onError` callback (renamed from `onPreStreamError` in v6). Mid-stream errors thrown after the first yield always go through `onMidStreamError` since HTTP status is already committed.
|
|
408
408
|
|
|
409
409
|
```typescript
|
|
410
410
|
// GOOD
|
|
411
|
+
import { defineErrorTaxonomy } from 'ts-procedures/http-errors'
|
|
412
|
+
|
|
411
413
|
new HonoStreamAppBuilder({
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
},
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
},
|
|
414
|
+
// Taxonomy handles all pre-stream errors (validation, auth, context)
|
|
415
|
+
errors: defineErrorTaxonomy({
|
|
416
|
+
AuthError: { class: AuthError, statusCode: 401 },
|
|
417
|
+
}),
|
|
418
|
+
unknownError: { toResponse: (err) => ({ error: (err as Error).message }) },
|
|
419
|
+
// Mid-stream still goes through onMidStreamError — the HTTP status is
|
|
420
|
+
// already committed, so errors become yields, not responses.
|
|
421
|
+
onMidStreamError: (proc, c, error) => ({ data: { error: error.message }, closeStream: true }),
|
|
420
422
|
})
|
|
421
423
|
```
|
|
422
424
|
|
|
@@ -474,13 +476,13 @@ import { DocRegistry } from 'ts-procedures/http-docs'
|
|
|
474
476
|
const docs = new DocRegistry({
|
|
475
477
|
basePath: '/api',
|
|
476
478
|
headers: [{ name: 'Authorization', description: 'Bearer token' }],
|
|
477
|
-
errors:
|
|
479
|
+
errors: appErrors, // your ErrorTaxonomy — framework defaults auto-merged
|
|
478
480
|
}).from(rpcBuilder).from(apiBuilder).from(streamBuilder)
|
|
479
481
|
|
|
480
482
|
app.get('/docs', (c) => c.json(docs.toJSON()))
|
|
481
483
|
```
|
|
482
484
|
|
|
483
|
-
**Why:** `DocRegistry` provides a typed `DocEnvelope`,
|
|
485
|
+
**Why:** `DocRegistry` provides a typed `DocEnvelope`, auto-merges framework error defaults (`ProcedureError`, `ProcedureValidationError`, `ProcedureYieldValidationError`, `ProcedureRegistrationError`) with JSON Schemas, reads docs lazily (order-independent), and supports filtering and transformation via `toJSON()` options.
|
|
484
486
|
|
|
485
487
|
---
|
|
486
488
|
|
|
@@ -496,21 +498,37 @@ builder.register(factory, async (req) => {
|
|
|
496
498
|
})
|
|
497
499
|
```
|
|
498
500
|
|
|
499
|
-
**Fix:**
|
|
501
|
+
**Fix:** Throw a typed error class from the context factory and register it in the builder's `errors` taxonomy — the builder auto-serializes.
|
|
500
502
|
|
|
501
503
|
```typescript
|
|
502
504
|
// GOOD
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
505
|
+
import { defineErrorTaxonomy } from 'ts-procedures/http-errors'
|
|
506
|
+
|
|
507
|
+
class AuthError extends Error {
|
|
508
|
+
constructor(message: string) { super(message); this.name = 'AuthError' }
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const appErrors = defineErrorTaxonomy({
|
|
512
|
+
AuthError: {
|
|
513
|
+
class: AuthError,
|
|
514
|
+
statusCode: 401,
|
|
515
|
+
toResponse: () => ({ message: 'Unauthorized' }),
|
|
510
516
|
},
|
|
511
517
|
})
|
|
518
|
+
|
|
519
|
+
const app = new ExpressRPCAppBuilder({
|
|
520
|
+
errors: appErrors,
|
|
521
|
+
unknownError: { toResponse: () => ({ error: 'Internal server error' }) },
|
|
522
|
+
})
|
|
523
|
+
.register(factory, async (req) => {
|
|
524
|
+
const user = await authenticate(req.headers.authorization)
|
|
525
|
+
if (!user) throw new AuthError('invalid token')
|
|
526
|
+
return { userId: user.id }
|
|
527
|
+
})
|
|
512
528
|
```
|
|
513
529
|
|
|
530
|
+
The imperative `onError: (procedure, req, res, error) => void` callback is a first-class peer — use it for apps that prefer a single response handler or when you want to handle the tail of errors the taxonomy doesn't cover.
|
|
531
|
+
|
|
514
532
|
---
|
|
515
533
|
|
|
516
534
|
## 18. Using Both schema.params and schema.input
|
|
@@ -592,6 +610,55 @@ Create('GetUser', {
|
|
|
592
610
|
|
|
593
611
|
---
|
|
594
612
|
|
|
613
|
+
## 20. Hand-writing `onError` instanceof ladders
|
|
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.
|
|
616
|
+
|
|
617
|
+
```typescript
|
|
618
|
+
// BAD — duplicated across builders, invisible to generated clients
|
|
619
|
+
new HonoAPIAppBuilder({
|
|
620
|
+
onError: (procedure, c, error) => {
|
|
621
|
+
if (error instanceof ValidationError) return c.json({ error: error.message }, 400)
|
|
622
|
+
if (error instanceof AuthError) return c.json({ error: 'Unauthorized' }, 401)
|
|
623
|
+
if (error instanceof UseCaseError) return c.json({ message: error.externalMsg }, 422)
|
|
624
|
+
return c.json({ error: 'Internal server error' }, 500)
|
|
625
|
+
},
|
|
626
|
+
})
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
**Fix:** Declare the taxonomy once with `defineErrorTaxonomy`, pass it via the builder's `errors` config, and handlers `throw` their own classes. The taxonomy also feeds the DocEnvelope so generated clients throw typed class instances.
|
|
630
|
+
|
|
631
|
+
```typescript
|
|
632
|
+
// GOOD
|
|
633
|
+
import { defineErrorTaxonomy } from 'ts-procedures/http-errors'
|
|
634
|
+
|
|
635
|
+
const appErrors = defineErrorTaxonomy({
|
|
636
|
+
ValidationError: { class: ValidationError, statusCode: 400 },
|
|
637
|
+
AuthError: { class: AuthError, statusCode: 401 },
|
|
638
|
+
UseCaseError: {
|
|
639
|
+
class: UseCaseError,
|
|
640
|
+
statusCode: 422,
|
|
641
|
+
toResponse: (err) => ({ message: err.externalMsg }), // `name` auto-injected
|
|
642
|
+
},
|
|
643
|
+
})
|
|
644
|
+
|
|
645
|
+
new HonoAPIAppBuilder({
|
|
646
|
+
errors: appErrors,
|
|
647
|
+
unknownError: {
|
|
648
|
+
toResponse: () => ({ error: 'Internal server error' }),
|
|
649
|
+
onCatch: (err, { procedure }) => logger.error({ procedure: procedure.name, err }),
|
|
650
|
+
},
|
|
651
|
+
})
|
|
652
|
+
```
|
|
653
|
+
|
|
654
|
+
The same `errors` / `unknownError` shape plugs into every builder (`HonoAPIAppBuilder`, `HonoRPCAppBuilder`, `ExpressRPCAppBuilder`, `HonoStreamAppBuilder` pre-stream only). `ctx.error()` still works and is caught by the default taxonomy at 500.
|
|
655
|
+
|
|
656
|
+
**The anti-pattern is the ladder, not `onError`.** `onError` is a first-class peer of the taxonomy — it's perfectly fine for simple apps or when you want full response control. The problem is writing `instanceof` ladders *inside* it to route by error class, because that's exactly what the taxonomy is designed to express declaratively.
|
|
657
|
+
|
|
658
|
+
**Why:** The taxonomy is declarative (easy to audit), shared across builders, and drives client codegen — the generated `_errors.ts` emits real runtime classes so consumers can `catch (err) { if (err instanceof ApiErrors.UseCaseError) ... }` with full typing.
|
|
659
|
+
|
|
660
|
+
---
|
|
661
|
+
|
|
595
662
|
## Summary Table
|
|
596
663
|
|
|
597
664
|
| # | Anti-Pattern | Risk | Severity |
|
|
@@ -615,3 +682,4 @@ Create('GetUser', {
|
|
|
615
682
|
| 17 | Unhandled async context factory | Request crashes | WARNING |
|
|
616
683
|
| 18 | Both schema.params and schema.input | ProcedureRegistrationError at startup | CRITICAL |
|
|
617
684
|
| 19 | Mismatched path param names | Build-time error or confusing validation failures | CRITICAL |
|
|
685
|
+
| 20 | Hand-writing onError instanceof ladders | Drifting response shapes, untyped client errors | WARNING |
|
|
@@ -256,6 +256,119 @@ class ProcedureRegistrationError extends ProcedureError {
|
|
|
256
256
|
|
|
257
257
|
---
|
|
258
258
|
|
|
259
|
+
## Error Taxonomy API
|
|
260
|
+
|
|
261
|
+
Exported from `ts-procedures/http-errors` (and re-exported from every HTTP builder subpath: `ts-procedures/hono-api`, `ts-procedures/hono-rpc`, `ts-procedures/express-rpc`, `ts-procedures/hono-stream`).
|
|
262
|
+
|
|
263
|
+
### defineErrorTaxonomy(entries)
|
|
264
|
+
|
|
265
|
+
```typescript
|
|
266
|
+
function defineErrorTaxonomy<T extends ErrorTaxonomy>(entries: T): T
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
Identity helper that:
|
|
270
|
+
1. Validates each entry has exactly one discriminator (`class` xor `match`) — throws otherwise.
|
|
271
|
+
2. Topologically sorts `class:` entries so subclasses always precede base classes regardless of declared order. Predicate (`match:`) entries keep declared order.
|
|
272
|
+
|
|
273
|
+
### ErrorTaxonomyEntry
|
|
274
|
+
|
|
275
|
+
```typescript
|
|
276
|
+
type ErrorTaxonomyEntry<TError = any, TBody = unknown> = {
|
|
277
|
+
class?: new (...args: any[]) => TError // instanceof discriminator — XOR with `match`
|
|
278
|
+
match?: (err: unknown) => err is TError // predicate discriminator — for 3rd-party errors
|
|
279
|
+
statusCode: number
|
|
280
|
+
description?: string // consumed by DocRegistry
|
|
281
|
+
schema?: Record<string, unknown> // response-body JSON Schema; consumed by DocRegistry
|
|
282
|
+
toResponse?: (err: TError, meta: { key: string }) => TBody
|
|
283
|
+
onCatch?: (
|
|
284
|
+
err: TError,
|
|
285
|
+
ctx: { procedure: TAnyProcedureRegistration; key: string; raw: unknown }
|
|
286
|
+
) => void | Promise<void>
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
type ErrorTaxonomy = Record<string, ErrorTaxonomyEntry>
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
When `toResponse` is omitted, the body defaults to `{ name: key, message: err.message }`. When `toResponse` returns an object without a `name` field, the resolver auto-injects `{ name: key }` — wire-protocol consistency is guaranteed.
|
|
293
|
+
|
|
294
|
+
### defaultErrorTaxonomy
|
|
295
|
+
|
|
296
|
+
Pre-built taxonomy covering `ProcedureValidationError` (400), `ProcedureYieldValidationError` (500), and direct `ProcedureError` (500, unwrapped throws only — wrapped ones fall through so user taxonomy / `unknownError` sees the real error). Applied as a fallback after the user taxonomy unless `includeDefaults: false`.
|
|
297
|
+
|
|
298
|
+
Taxonomy-to-doc conversion is handled automatically by `DocRegistry` when you pass your taxonomy to the constructor. There is no public helper.
|
|
299
|
+
|
|
300
|
+
### resolveErrorResponse(params)
|
|
301
|
+
|
|
302
|
+
```typescript
|
|
303
|
+
function resolveErrorResponse(params: {
|
|
304
|
+
err: unknown
|
|
305
|
+
userTaxonomy?: ErrorTaxonomy
|
|
306
|
+
includeDefaults?: boolean // default true
|
|
307
|
+
unknownError?: UnknownErrorConfig
|
|
308
|
+
procedure: TAnyProcedureRegistration
|
|
309
|
+
raw: unknown
|
|
310
|
+
}): { statusCode: number; body: unknown; runOnCatch: () => Promise<void> } | null
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
Match order: user taxonomy → default taxonomy (when `includeDefaults`) → `unknownError`. Returns `null` when nothing matches — callers fall through to their builder's imperative `onError` callback and then the hard default. Unwraps `ProcedureError.cause` so user taxonomy matches against the real thrown error, not the wrapper. Side effects (`onCatch`) are deferred into `runOnCatch` so the caller decides when to execute them.
|
|
314
|
+
|
|
315
|
+
### UnknownErrorConfig
|
|
316
|
+
|
|
317
|
+
```typescript
|
|
318
|
+
type UnknownErrorConfig = {
|
|
319
|
+
statusCode?: number // default 500
|
|
320
|
+
toResponse: (err: unknown) => unknown
|
|
321
|
+
onCatch?: (
|
|
322
|
+
err: unknown,
|
|
323
|
+
ctx: { procedure: TAnyProcedureRegistration; raw: unknown }
|
|
324
|
+
) => void | Promise<void>
|
|
325
|
+
}
|
|
326
|
+
```
|
|
327
|
+
|
|
328
|
+
---
|
|
329
|
+
|
|
330
|
+
## Client Error Registry API
|
|
331
|
+
|
|
332
|
+
Exported from `ts-procedures/client`. Generated clients hook their `${Service}ErrorRegistry` into `createClient` so non-2xx responses arrive as typed class instances.
|
|
333
|
+
|
|
334
|
+
### ErrorRegistry / ErrorFactory / ErrorResponseMeta
|
|
335
|
+
|
|
336
|
+
```typescript
|
|
337
|
+
interface ErrorResponseMeta { status: number; procedureName: string; scope: string }
|
|
338
|
+
interface ErrorFactory { fromResponse(body: unknown, meta: ErrorResponseMeta): Error }
|
|
339
|
+
type ErrorRegistry = Record<string, ErrorFactory>
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
### dispatchTypedError(registry, body, meta)
|
|
343
|
+
|
|
344
|
+
```typescript
|
|
345
|
+
function dispatchTypedError(
|
|
346
|
+
registry: ErrorRegistry | undefined,
|
|
347
|
+
body: unknown,
|
|
348
|
+
meta: ErrorResponseMeta
|
|
349
|
+
): Error | null
|
|
350
|
+
```
|
|
351
|
+
|
|
352
|
+
Returns a typed error instance when:
|
|
353
|
+
- `registry` is defined, AND
|
|
354
|
+
- `body` is an object with a string `name`, AND
|
|
355
|
+
- `registry[body.name].fromResponse(body, meta)` returns an `Error` subclass.
|
|
356
|
+
|
|
357
|
+
Otherwise returns `null`; callers fall back to `ClientRequestError`.
|
|
358
|
+
|
|
359
|
+
### CreateClientConfig.errorRegistry
|
|
360
|
+
|
|
361
|
+
```typescript
|
|
362
|
+
interface CreateClientConfig<TScopes> {
|
|
363
|
+
// ...existing fields
|
|
364
|
+
errorRegistry?: ErrorRegistry
|
|
365
|
+
}
|
|
366
|
+
```
|
|
367
|
+
|
|
368
|
+
Threaded into both `executeCall` and `executeStream`. The generated `create${Service}Client(config)` factory wires this automatically.
|
|
369
|
+
|
|
370
|
+
---
|
|
371
|
+
|
|
259
372
|
## Schema Utilities
|
|
260
373
|
|
|
261
374
|
### extractJsonSchema(libSchema)
|
|
@@ -308,7 +421,11 @@ class ExpressRPCAppBuilder {
|
|
|
308
421
|
onRequestStart?: (req: express.Request) => void
|
|
309
422
|
onRequestEnd?: (req: express.Request, res: express.Response) => void
|
|
310
423
|
onSuccess?: (procedure: TProcedureRegistration, req: express.Request, res: express.Response) => void
|
|
424
|
+
// Error handling — two peer modes plus a cross-cutting observer.
|
|
425
|
+
errors?: ErrorTaxonomy
|
|
426
|
+
unknownError?: UnknownErrorConfig
|
|
311
427
|
onError?: (procedure: TProcedureRegistration, req: express.Request, res: express.Response, error: Error) => void
|
|
428
|
+
onRequestError?: (ctx: OnRequestErrorContext) => void | Promise<void>
|
|
312
429
|
})
|
|
313
430
|
|
|
314
431
|
register<TFactory>(
|
|
@@ -326,6 +443,8 @@ class ExpressRPCAppBuilder {
|
|
|
326
443
|
}
|
|
327
444
|
```
|
|
328
445
|
|
|
446
|
+
`OnRequestErrorContext` for Express: `{ err: unknown; procedure: TProcedureRegistration; raw: { req, res } }`.
|
|
447
|
+
|
|
329
448
|
### Route Path
|
|
330
449
|
|
|
331
450
|
`POST {pathPrefix}/{scope}/{kebab-name}/{version}`
|
|
@@ -357,7 +476,11 @@ class HonoRPCAppBuilder {
|
|
|
357
476
|
onRequestStart?: (c: Context) => void
|
|
358
477
|
onRequestEnd?: (c: Context) => void
|
|
359
478
|
onSuccess?: (procedure: TProcedureRegistration, c: Context) => void
|
|
479
|
+
// Error handling — two peer modes plus a cross-cutting observer.
|
|
480
|
+
errors?: ErrorTaxonomy
|
|
481
|
+
unknownError?: UnknownErrorConfig
|
|
360
482
|
onError?: (procedure: TProcedureRegistration, c: Context, error: Error) => Response | Promise<Response>
|
|
483
|
+
onRequestError?: (ctx: OnRequestErrorContext) => void | Promise<void>
|
|
361
484
|
})
|
|
362
485
|
|
|
363
486
|
register<TFactory>(
|
|
@@ -372,6 +495,8 @@ class HonoRPCAppBuilder {
|
|
|
372
495
|
}
|
|
373
496
|
```
|
|
374
497
|
|
|
498
|
+
`OnRequestErrorContext` for Hono RPC: `{ err: unknown; procedure: TProcedureRegistration; raw: Context }`.
|
|
499
|
+
|
|
375
500
|
### Signal Injection
|
|
376
501
|
|
|
377
502
|
Uses `c.req.raw.signal` — the native Request signal from the Hono context.
|
|
@@ -392,7 +517,10 @@ class HonoStreamAppBuilder<TErrorData = unknown> {
|
|
|
392
517
|
onRequestEnd?: (c: Context) => void
|
|
393
518
|
onStreamStart?: (procedure: TStreamProcedureRegistration, c: Context, streamMode: StreamMode) => void
|
|
394
519
|
onStreamEnd?: (procedure: TStreamProcedureRegistration, c: Context, streamMode: StreamMode) => void
|
|
395
|
-
|
|
520
|
+
errors?: ErrorTaxonomy
|
|
521
|
+
unknownError?: UnknownErrorConfig
|
|
522
|
+
onError?: (procedure: TStreamProcedureRegistration, c: Context, error: Error) => Response | Promise<Response> // renamed from onPreStreamError in v6
|
|
523
|
+
onRequestError?: (ctx: { err: unknown; procedure: TStreamProcedureRegistration; raw: Context }) => void | Promise<void>
|
|
396
524
|
onMidStreamError?: (procedure: TStreamProcedureRegistration, c: Context, error: Error) => MidStreamErrorResult<TErrorData> | undefined
|
|
397
525
|
})
|
|
398
526
|
|
|
@@ -451,8 +579,8 @@ type MidStreamErrorResult<TErrorData = unknown> = {
|
|
|
451
579
|
|
|
452
580
|
### Error Handling
|
|
453
581
|
|
|
454
|
-
- **Pre-stream errors** (validation, context): Returns JSON error response (no streaming started).
|
|
455
|
-
- **Mid-stream errors** (generator throws): Yields error as final event, closes stream.
|
|
582
|
+
- **Pre-stream errors** (validation, context): Returns a JSON error response (no streaming started). Handle via either peer mode — `errors` taxonomy or `onError` callback. Observe via `onRequestError`.
|
|
583
|
+
- **Mid-stream errors** (generator throws): Yields error as final event, closes stream. Handle via `onMidStreamError` (the only mid-stream path — the HTTP status is already committed).
|
|
456
584
|
|
|
457
585
|
---
|
|
458
586
|
|
|
@@ -469,7 +597,11 @@ class HonoAPIAppBuilder {
|
|
|
469
597
|
onRequestStart?: (c: Context) => void
|
|
470
598
|
onRequestEnd?: (c: Context) => void
|
|
471
599
|
onSuccess?: (procedure: TProcedureRegistration, c: Context) => void
|
|
600
|
+
// Error handling — two peer modes plus a cross-cutting observer.
|
|
601
|
+
errors?: ErrorTaxonomy
|
|
602
|
+
unknownError?: UnknownErrorConfig
|
|
472
603
|
onError?: (procedure: TProcedureRegistration, c: Context, error: Error) => Response | Promise<Response>
|
|
604
|
+
onRequestError?: (ctx: OnRequestErrorContext) => void | Promise<void>
|
|
473
605
|
})
|
|
474
606
|
|
|
475
607
|
register<TFactory>(
|
|
@@ -484,6 +616,8 @@ class HonoAPIAppBuilder {
|
|
|
484
616
|
}
|
|
485
617
|
```
|
|
486
618
|
|
|
619
|
+
`OnRequestErrorContext` for Hono API: `{ err: unknown; procedure: TProcedureRegistration; raw: Context }`.
|
|
620
|
+
|
|
487
621
|
### Route Path
|
|
488
622
|
|
|
489
623
|
Developer-defined via `APIConfig.path` — no auto-generation. Supports Hono path params (`:id`).
|
|
@@ -553,9 +687,12 @@ Uses `c.req.raw.signal` — native Request signal from Hono context.
|
|
|
553
687
|
### RPCConfig
|
|
554
688
|
|
|
555
689
|
```typescript
|
|
556
|
-
|
|
690
|
+
// Generic over valid taxonomy keys. Default TErrorKey = string (permissive).
|
|
691
|
+
// Narrow via `RPCConfig<keyof typeof appErrors & string>` for compile-time typo protection.
|
|
692
|
+
interface RPCConfig<TErrorKey extends string = string> {
|
|
557
693
|
scope: string | string[]
|
|
558
694
|
version: number
|
|
695
|
+
errors?: TErrorKey[] // Taxonomy keys this procedure may emit (informational; populates DocEnvelope).
|
|
559
696
|
}
|
|
560
697
|
```
|
|
561
698
|
|
|
@@ -570,6 +707,7 @@ interface RPCHttpRouteDoc extends RPCConfig {
|
|
|
570
707
|
body?: Record<string, unknown>
|
|
571
708
|
response?: Record<string, unknown>
|
|
572
709
|
}
|
|
710
|
+
errors?: string[] // Per-route subset, populated from config.errors.
|
|
573
711
|
}
|
|
574
712
|
```
|
|
575
713
|
|
|
@@ -586,16 +724,20 @@ interface StreamHttpRouteDoc extends RPCConfig {
|
|
|
586
724
|
yieldType?: Record<string, unknown>
|
|
587
725
|
returnType?: Record<string, unknown>
|
|
588
726
|
}
|
|
727
|
+
errors?: string[]
|
|
589
728
|
}
|
|
590
729
|
```
|
|
591
730
|
|
|
592
731
|
### APIConfig
|
|
593
732
|
|
|
594
733
|
```typescript
|
|
595
|
-
|
|
734
|
+
// Generic over valid taxonomy keys — same pattern as RPCConfig.
|
|
735
|
+
interface APIConfig<TErrorKey extends string = string> {
|
|
596
736
|
path: string
|
|
597
737
|
method: 'get' | 'post' | 'put' | 'delete' | 'patch' | 'head'
|
|
598
738
|
successStatus?: number
|
|
739
|
+
scope?: string
|
|
740
|
+
errors?: TErrorKey[]
|
|
599
741
|
}
|
|
600
742
|
```
|
|
601
743
|
|
|
@@ -612,6 +754,7 @@ interface APIHttpRouteDoc extends APIConfig {
|
|
|
612
754
|
headers?: Record<string, unknown>
|
|
613
755
|
response?: Record<string, unknown>
|
|
614
756
|
}
|
|
757
|
+
errors?: string[]
|
|
615
758
|
}
|
|
616
759
|
```
|
|
617
760
|
|
|
@@ -646,19 +789,22 @@ import type { DocEnvelope, DocRegistryConfig, DocRegistryOutputOptions, HeaderDo
|
|
|
646
789
|
```
|
|
647
790
|
|
|
648
791
|
```typescript
|
|
792
|
+
import { DocRegistry } from 'ts-procedures/http-docs'
|
|
793
|
+
import type { DocEnvelope, DocRegistryConfig, DocRegistryOutputOptions, HeaderDoc, ErrorDoc, DocSource } from 'ts-procedures/http-docs'
|
|
794
|
+
|
|
795
|
+
interface DocRegistryConfig {
|
|
796
|
+
basePath?: string
|
|
797
|
+
headers?: HeaderDoc[]
|
|
798
|
+
errors?: ErrorTaxonomy | ErrorDoc[] // polymorphic — pass taxonomy or raw docs
|
|
799
|
+
includeDefaults?: boolean // default: true (auto-merges framework defaults)
|
|
800
|
+
}
|
|
801
|
+
|
|
649
802
|
class DocRegistry {
|
|
650
|
-
constructor(config?:
|
|
651
|
-
basePath?: string
|
|
652
|
-
headers?: HeaderDoc[]
|
|
653
|
-
errors?: ErrorDoc[]
|
|
654
|
-
})
|
|
803
|
+
constructor(config?: DocRegistryConfig)
|
|
655
804
|
|
|
656
805
|
from(source: DocSource<AnyHttpRouteDoc>): this
|
|
657
|
-
|
|
658
|
-
toJSON<T = DocEnvelope>(options?:
|
|
659
|
-
filter?: (route: AnyHttpRouteDoc) => boolean
|
|
660
|
-
transform?: (envelope: DocEnvelope) => T
|
|
661
|
-
}): T
|
|
806
|
+
documentError(...docs: ErrorDoc[]): this
|
|
807
|
+
toJSON<T = DocEnvelope>(options?: DocRegistryOutputOptions<T>): T
|
|
662
808
|
|
|
663
809
|
static defaultErrors(): ErrorDoc[]
|
|
664
810
|
}
|
|
@@ -717,7 +863,7 @@ type AnyHttpRouteDoc = RPCHttpRouteDoc | APIHttpRouteDoc | StreamHttpRouteDoc
|
|
|
717
863
|
|
|
718
864
|
- `from()` stores a **reference** to the builder — routes are read lazily at `toJSON()` time, so builders can be registered before or after `.build()`
|
|
719
865
|
- `toJSON()` collects routes via `flatMap(source => source.docs)`, applies optional `filter`, builds envelope, then applies optional `transform`
|
|
720
|
-
- `defaultErrors()` returns 4 `ErrorDoc` entries describing `ProcedureError` (500), `ProcedureValidationError` (400), `ProcedureYieldValidationError` (500), `ProcedureRegistrationError` (500) — each with a JSON Schema for the error response body shape
|
|
866
|
+
- `defaultErrors()` returns 4 `ErrorDoc` entries describing `ProcedureError` (500), `ProcedureValidationError` (400), `ProcedureYieldValidationError` (500), `ProcedureRegistrationError` (500) — each with a JSON Schema for the error response body shape. Most consumers do not need to call this directly; the `DocRegistry` constructor auto-merges these unless `includeDefaults: false` is passed.
|
|
721
867
|
- `JSON.stringify(registry)` works directly (calls `toJSON()` implicitly)
|
|
722
868
|
|
|
723
869
|
---
|