Skip to content

Commit 2c5236d

Browse files
committed
docs: cover typed response maps
Document the user-visible response map API added on this branch: <T>(), generated client tuple results, ctx.error, ctx.success, and response-plugin success entries inside maps. Add a runnable typed error response example and keep public TSDoc aligned so declaration docs describe response maps alongside direct JSON and plugin responses.
1 parent 688bb65 commit 2c5236d

8 files changed

Lines changed: 306 additions & 36 deletions

File tree

README.md

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,14 @@ and Zod validation between a Hattip-compatible server and a typed fetch client.
66
## What it does
77

88
A Rouzer HTTP route tree defines URL patterns, named actions, method schemas, and
9-
optional JSON or newline-delimited JSON response types once, then reuses that
10-
contract to:
9+
optional JSON, error, or newline-delimited JSON response types once, then reuses
10+
that contract to:
1111

1212
- validate client arguments before `fetch`
1313
- match and validate server requests before handlers run
1414
- type handler context from path, query/body, headers, and middleware
1515
- attach typed client action functions such as `client.profiles.get(...)`
16-
- parse typed JSON responses and typed NDJSON response streams
16+
- parse typed JSON responses, declared error responses, and NDJSON streams
1717

1818
Rouzer optimizes for shared TypeScript route modules over language-agnostic API
1919
schemas or generated SDKs.
@@ -31,8 +31,8 @@ Consider something else if:
3131

3232
- you need OpenAPI-first workflows, schema files, or generated clients for other
3333
languages
34-
- you need runtime response-body validation; `response: $type<T>()` and
35-
`response: ndjson.$type<T>()` are compile-time only
34+
- you need runtime response-body validation; `$type<T>()`, `$error<T>()`, and
35+
`ndjson.$type<T>()` are compile-time only
3636
- you want a framework that owns controllers, data loading, rendering, and
3737
deployment adapters
3838
- you cannot use ESM or Zod v4+
@@ -55,7 +55,7 @@ Import the primary API from the root package and declare routes through the HTTP
5555
subpath:
5656

5757
```ts
58-
import { $type, chain, createClient, createRouter } from 'rouzer'
58+
import { $error, $type, chain, createClient, createRouter } from 'rouzer'
5959
import * as http from 'rouzer/http'
6060
```
6161

@@ -103,6 +103,49 @@ const { message } = await client.hello({
103103
route arguments before `fetch`; server handlers validate matched path, query,
104104
headers, and JSON bodies before your handler runs.
105105

106+
### Typed status responses
107+
108+
Use a response map when client code needs declared error statuses as data instead
109+
of exceptions.
110+
111+
```ts
112+
import { $error, $type, createClient, createRouter } from 'rouzer'
113+
import * as http from 'rouzer/http'
114+
115+
type User = { id: string; name: string }
116+
type NotFound = { code: 'NOT_FOUND'; message: string }
117+
118+
export const getUser = http.get('users/:id', {
119+
response: {
120+
200: $type<User>(),
121+
404: $error<NotFound>(),
122+
},
123+
})
124+
export const routes = { getUser }
125+
126+
createRouter().use(routes, {
127+
getUser(ctx) {
128+
if (ctx.path.id === 'missing') {
129+
return ctx.error(404, {
130+
code: 'NOT_FOUND',
131+
message: 'User not found',
132+
})
133+
}
134+
return { id: ctx.path.id, name: 'Ada' }
135+
},
136+
})
137+
138+
const client = createClient({
139+
baseURL: 'https://example.com/api/',
140+
routes,
141+
})
142+
143+
const [error, user, status] = await client.getUser({ path: { id: '42' } })
144+
```
145+
146+
Success entries resolve as `[null, value, status]`; declared error entries
147+
resolve as `[error, null, status]`.
148+
106149
### NDJSON response streams
107150

108151
Use `response: ndjson.$type<T>()` for endpoints that stream
@@ -141,6 +184,7 @@ for await (const event of await client.events()) {
141184

142185
- [Concepts, API selection, and v2->v3 migration notes](docs/context.md)
143186
- [Runnable shared-route example](examples/basic-usage.ts)
187+
- [Runnable typed error response example](examples/error-responses.ts)
144188
- [Runnable NDJSON response-stream example](examples/ndjson-stream.ts)
145189
- Generated declarations in the published package provide the exact signatures
146190
for every public export, including the `rouzer/http` and `rouzer/ndjson`

docs/context.md

Lines changed: 120 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@
22

33
Rouzer is for applications that want one TypeScript HTTP route tree to drive
44
both the server and the client that calls it. A route tree combines URL
5-
patterns, named actions, HTTP method schemas, and optional compile-time JSON or
6-
NDJSON response types.
5+
patterns, named actions, HTTP method schemas, and optional compile-time success,
6+
error, or plugin response types.
77

88
## When to use Rouzer
99

@@ -92,11 +92,45 @@ The HTTP action API models explicit operations. It does not expose the old
9292
method-map `ALL` fallback route shape; declare the concrete methods your client
9393
and server support.
9494

95-
### `$type<T>()` and `ndjson.$type<T>()`
95+
### Response markers and maps
9696

97-
`response: $type<T>()` is a TypeScript-only marker for JSON response payloads.
98-
It tells handlers and client action functions what response payload type to
99-
expect, but Rouzer does not validate response bodies at runtime.
97+
`response: $type<T>()` is a TypeScript-only marker for JSON success payloads. It
98+
tells handlers and client action functions what payload type to expect, but
99+
Rouzer does not validate response bodies at runtime.
100+
101+
Use a status-keyed response map when callers need to branch on declared statuses:
102+
103+
```ts
104+
import { $error, $type } from 'rouzer'
105+
import * as http from 'rouzer/http'
106+
107+
type User = { id: string; name: string }
108+
type NotFound = { code: 'NOT_FOUND'; message: string }
109+
110+
export const getUser = http.get('users/:id', {
111+
response: {
112+
200: $type<User>(),
113+
201: $type<User>(),
114+
404: $error<NotFound>(),
115+
},
116+
})
117+
```
118+
119+
Success entries use `$type<T>()` or a response plugin marker. Error entries use
120+
`$error<T>()` and are encoded as JSON. Generated client action functions resolve
121+
declared statuses as tuples:
122+
123+
- success: `[null, value, status]`
124+
- error: `[error, null, status]`
125+
126+
Declared error statuses do not reject the client promise. Undeclared statuses
127+
still go through `onJsonError` or throw the default error.
128+
129+
Handlers for response-map actions may return the default success value directly,
130+
use `ctx.success(status, body)` to choose a declared success status, or use
131+
`ctx.error(status, body)` to return a declared error status. The `ctx.error` and
132+
`ctx.success` helpers only accept statuses and bodies declared in the response
133+
map.
100134

101135
`response: ndjson.$type<T>()` is a TypeScript-only marker for newline-delimited
102136
JSON response streams from the `rouzer/ndjson` subpath. Register
@@ -109,8 +143,8 @@ response body. Streamed items are parsed as JSON but are not validated against a
109143
Zod schema.
110144

111145
Actions without a `response` marker return a raw `Response` from client action
112-
functions. Actions with `response: $type<T>()` use `client.json(...)` under the
113-
hood and return parsed JSON typed as `T`.
146+
functions. Actions with `response: $type<T>()` return parsed JSON typed as `T`.
147+
Actions with a response map return the tuple union described by that map.
114148

115149
### Response plugins
116150

@@ -121,10 +155,11 @@ matching runtime plugins. For NDJSON, those are `ndjson.$type<T>()`,
121155

122156
The router plugin encodes non-`Response` handler results into an HTTP `Response`.
123157
The client plugin decodes successful HTTP responses for generated client action
124-
functions. Rouzer validates plugin registration when routes are attached to a
125-
router or client, so routes that use an unregistered response marker fail fast
126-
instead of falling back to JSON. Response plugins do not automatically validate
127-
response payloads unless the plugin itself implements validation.
158+
functions. Plugin markers can also be success entries in a status-keyed response
159+
map. Rouzer validates plugin registration when routes are attached to a router or
160+
client, so routes that use an unregistered response marker fail fast instead of
161+
falling back to JSON. Response plugins do not automatically validate response
162+
payloads unless the plugin itself implements validation.
128163

129164
### Router
130165

@@ -157,6 +192,8 @@ Handlers receive a context typed from middleware plus the action schema:
157192
- `GET` handlers receive `ctx.path`, `ctx.query`, and `ctx.headers`
158193
- mutation handlers receive `ctx.path`, `ctx.body`, and `ctx.headers`
159194
- handlers may return a plain JSON-serializable value or a `Response`
195+
- response-map handlers can return a default success value directly or use
196+
`ctx.success(status, body)` and `ctx.error(status, body)`
160197
- `ndjson.$type<T>()` handlers return an `Iterable<T>` or `AsyncIterable<T>`
161198
unless they return a custom `Response`
162199
- plain values are returned with `Response.json(value)`
@@ -175,6 +212,8 @@ requests with an `Origin` header.
175212
request factory contains the full path you want to call
176213
- `client.json(action.request(args))` for parsed JSON and default non-2xx
177214
throwing
215+
- response-map support for generated client action functions, returning
216+
`[error, value, status]` tuples for declared statuses
178217
- response plugin support for generated client action functions, such as
179218
`ndjson.clientPlugin` for NDJSON response streams
180219
- a client tree that mirrors `routes`, with action functions such as
@@ -205,9 +244,9 @@ runtimes.
205244
`fetch`.
206245
5. The router matches the request, validates the matched inputs, and calls the
207246
handler.
208-
6. Plain handler results become JSON responses, plugin handler results become
209-
plugin-encoded responses, and explicit `Response` objects pass through
210-
unchanged.
247+
6. Plain handler results become JSON responses, response-map helpers choose
248+
declared statuses, plugin handler results become plugin-encoded responses, and
249+
explicit `Response` objects pass through unchanged.
211250

212251
On the server, `path`, `query`, and `headers` values originate as strings. Rouzer
213252
coerces Zod `number` schemas with `Number(value)` and Zod `boolean` schemas from
@@ -247,9 +286,64 @@ const json = await client.json(
247286
)
248287
```
249288

250-
Response plugins are applied by generated client action functions. For longhand
251-
calls to plugin-backed routes, use `client.request(...)` for the raw `Response`
252-
and call the plugin subpath's decoder yourself.
289+
Response maps and response plugins are applied by generated client action
290+
functions. For longhand calls to mapped or plugin-backed routes, use
291+
`client.request(...)` for the raw `Response` and decode the response yourself.
292+
293+
### Handle declared error responses
294+
295+
Use `$error<T>()` inside a response map when an error status is part of the route
296+
contract:
297+
298+
```ts
299+
import { $error, $type, createClient, createRouter } from 'rouzer'
300+
import * as http from 'rouzer/http'
301+
302+
type User = { id: string; name: string }
303+
type NotFound = { code: 'NOT_FOUND'; message: string }
304+
305+
export const getUser = http.get('users/:id', {
306+
response: {
307+
200: $type<User>(),
308+
404: $error<NotFound>(),
309+
},
310+
})
311+
export const routes = { getUser }
312+
313+
createRouter().use(routes, {
314+
getUser(ctx) {
315+
if (ctx.path.id === 'missing') {
316+
return ctx.error(404, {
317+
code: 'NOT_FOUND',
318+
message: 'User not found',
319+
})
320+
}
321+
return { id: ctx.path.id, name: 'Ada' }
322+
},
323+
})
324+
325+
const client = createClient({
326+
baseURL: 'https://example.com/api/',
327+
routes,
328+
})
329+
330+
const [error, user, status] = await client.getUser({
331+
path: { id: 'missing' },
332+
})
333+
334+
if (status === 404) {
335+
console.log(error.message)
336+
} else {
337+
console.log(user.name)
338+
}
339+
```
340+
341+
A complete runnable version lives in
342+
[`examples/error-responses.ts`](../examples/error-responses.ts).
343+
344+
When a response map declares multiple success statuses, return a plain value for
345+
the default success status or use `ctx.success(status, body)` to choose a
346+
specific declared success status.
253347

254348
### Stream newline-delimited JSON
255349

@@ -322,8 +416,8 @@ custom headers. Return a plain value for the default `Response.json(value)` path
322416
### Customize JSON errors
323417

324418
By default, `client.json(...)` and generated client action functions throw for
325-
non-2xx responses. If the response body is JSON, its properties are copied onto
326-
the thrown `Error`.
419+
non-2xx responses that are not declared in a response map. If the response body
420+
is JSON, its properties are copied onto the thrown `Error`.
327421

328422
`onJsonError` can override that behavior. Its return value is returned from the
329423
response helper as-is; Rouzer does not automatically parse a returned `Response`
@@ -390,6 +484,8 @@ await client.profiles.update({
390484
only when string params are sufficient.
391485
- Use `response: $type<T>()` for JSON endpoints that should have typed client
392486
action functions.
487+
- Use response maps with `$error<T>()` when callers should handle declared error
488+
statuses as typed data instead of exceptions.
393489
- Use `response: ndjson.$type<T>()` plus `ndjson.routerPlugin` and
394490
`ndjson.clientPlugin` for response streams where each line is a JSON value and
395491
the client should consume an `AsyncIterable<T>`.
@@ -400,10 +496,12 @@ await client.profiles.update({
400496

401497
## Constraints and gotchas
402498

403-
- `$type<T>()` and `ndjson.$type<T>()` are compile-time only and do not validate
404-
response payloads or streamed items.
499+
- `$type<T>()`, `$error<T>()`, and `ndjson.$type<T>()` are compile-time only and
500+
do not validate response payloads or streamed items.
405501
- NDJSON support is for response streams; request bodies still use the existing
406502
JSON body schema path.
503+
- Declared `$error<T>()` responses are JSON responses. Use a custom `Response`
504+
for non-JSON error payloads.
407505
- Routes that use a response plugin fail fast if the matching client or router
408506
plugin is not registered.
409507
- Pathname route patterns expect an absolute client `baseURL`.

0 commit comments

Comments
 (0)