rouzer 3.1.0 → 4.0.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/dist/type.js CHANGED
@@ -1,9 +1,12 @@
1
1
  /**
2
2
  * Create a compile-time-only marker for an action's JSON response payload type.
3
3
  *
4
- * @remarks `$type<T>()` does not perform runtime validation. It lets Rouzer type
5
- * server handler return values and client action functions for HTTP actions
6
- * whose responses are expected to be JSON.
4
+ * @remarks `$type<T>()` does not validate handler return values at the server
5
+ * boundary. It lets Rouzer type server handler return values and client action
6
+ * functions for HTTP actions whose responses are expected to be JSON. Use it
7
+ * directly as `response` for one JSON success shape, or as a success entry in a
8
+ * status-keyed response map. Validate response data where it enters your server
9
+ * or client code when runtime integrity is required.
7
10
  *
8
11
  * @example
9
12
  * ```ts
@@ -19,3 +22,29 @@ export function $type() {
19
22
  return $type.symbol;
20
23
  }
21
24
  $type.symbol = Symbol();
25
+ /**
26
+ * Create a compile-time-only marker for a declared error response type.
27
+ *
28
+ * @remarks `$error<T>()` marks a non-success response branch in a status-keyed
29
+ * response map. It is a type contract, not a runtime validator. On the server,
30
+ * handlers use `ctx.error(status, body)` to return declared errors. On the
31
+ * client, declared error responses resolve as `[error, null, status]` tuple
32
+ * entries instead of rejecting the promise.
33
+ *
34
+ * @example
35
+ * ```ts
36
+ * import { $type, $error } from 'rouzer'
37
+ * import * as http from 'rouzer/http'
38
+ *
39
+ * const getUser = http.get('users/:id', {
40
+ * response: {
41
+ * 200: $type<User>(),
42
+ * 404: $error<{ code: string; message: string }>(),
43
+ * },
44
+ * })
45
+ * ```
46
+ */
47
+ export function $error() {
48
+ return $error.symbol;
49
+ }
50
+ $error.symbol = Symbol();
@@ -32,7 +32,7 @@ type MutationArgs<T> = T extends MutationRouteSchema ? T extends {
32
32
  body?: unknown;
33
33
  } : unknown;
34
34
  /**
35
- * Arguments accepted by a client action function or low-level request factory.
35
+ * Arguments accepted by a generated client action function.
36
36
  *
37
37
  * @remarks The type is derived from an action schema and route pattern. `path`,
38
38
  * `query`, `body`, and `headers` are validated by the client before `fetch` when
@@ -3,11 +3,61 @@ import type { AnyMiddlewareChain, MiddlewareContext } from 'alien-middleware';
3
3
  import type * as z from 'zod';
4
4
  import { Promisable } from '../common.js';
5
5
  import type { HttpAction } from '../http.js';
6
- import type { InferRouteHandlerResult } from './response.js';
7
- import type { RouteSchema } from './schema.js';
6
+ import type { InferRouteHandlerResult, InferResponseMapErrors, InferResponseMapSuccesses } from './response.js';
7
+ import type { RouteResponseMap, RouteSchema } from './schema.js';
8
8
  type RequestContext<TMiddleware extends AnyMiddlewareChain> = MiddlewareContext<TMiddleware>;
9
- export type RouteRequestHandler<TMiddleware extends AnyMiddlewareChain, TArgs extends object, TResult> = (context: RequestContext<TMiddleware> & TArgs) => Promisable<TResult | Response>;
10
- export type InferActionHandler<TMiddleware extends AnyMiddlewareChain, TAction extends HttpAction, TPath extends string> = TAction['method'] extends 'GET' ? RouteRequestHandler<TMiddleware, {
9
+ /**
10
+ * Error response returned by `ctx.error(status, body)` in route handlers.
11
+ *
12
+ * @remarks This is an opaque branded type returned by the error helper. Route
13
+ * handlers may return it to signal a declared error response.
14
+ */
15
+ export type RouteErrorResponse = Response & {
16
+ __routeError__: true;
17
+ };
18
+ /** Response returned by `ctx.success(status, body)` in route handlers. */
19
+ export type RouteSuccessResponse = Response & {
20
+ __routeSuccess__: true;
21
+ };
22
+ export type RouteRequestHandler<TMiddleware extends AnyMiddlewareChain, TArgs extends object, TResult, TErrors = never, TSuccesses = never> = (context: RequestContext<TMiddleware> & TArgs & ([TErrors] extends [never] ? {} : {
23
+ /**
24
+ * Return a declared error response.
25
+ *
26
+ * @remarks Only statuses declared with `$error<T>()` in the response
27
+ * map are accepted.
28
+ */
29
+ error: <TEntry extends TErrors>(...args: TEntry extends [infer S extends number, infer B] ? [status: S, body: B] : never) => RouteErrorResponse;
30
+ }) & ([TSuccesses] extends [never] ? {} : {
31
+ /**
32
+ * Return a declared success response with an explicit status.
33
+ *
34
+ * @remarks Useful when a response map declares multiple 2xx statuses.
35
+ */
36
+ success: <TEntry extends TSuccesses>(...args: TEntry extends [infer S extends number, infer B] ? [status: S, body: B] : never) => RouteSuccessResponse;
37
+ })) => Promisable<TResult | Response>;
38
+ export type InferActionHandler<TMiddleware extends AnyMiddlewareChain, TAction extends HttpAction, TPath extends string> = TAction['schema'] extends {
39
+ response: infer R extends RouteResponseMap;
40
+ } ? TAction['method'] extends 'GET' ? RouteRequestHandler<TMiddleware, {
41
+ path: TAction['schema'] extends {
42
+ path: any;
43
+ } ? z.infer<TAction['schema']['path']> : MatchParams<TPath>;
44
+ query: TAction['schema'] extends {
45
+ query: any;
46
+ } ? z.infer<TAction['schema']['query']> : undefined;
47
+ headers: TAction['schema'] extends {
48
+ headers: any;
49
+ } ? z.infer<TAction['schema']['headers']> : undefined;
50
+ }, InferRouteHandlerResult<Extract<TAction['schema'], RouteSchema>>, InferResponseMapErrors<R>, InferResponseMapSuccesses<R>> : RouteRequestHandler<TMiddleware, {
51
+ path: TAction['schema'] extends {
52
+ path: any;
53
+ } ? z.infer<TAction['schema']['path']> : MatchParams<TPath>;
54
+ body: TAction['schema'] extends {
55
+ body: any;
56
+ } ? z.infer<TAction['schema']['body']> : undefined;
57
+ headers: TAction['schema'] extends {
58
+ headers: any;
59
+ } ? z.infer<TAction['schema']['headers']> : undefined;
60
+ }, InferRouteHandlerResult<Extract<TAction['schema'], RouteSchema>>, InferResponseMapErrors<R>, InferResponseMapSuccesses<R>> : TAction['method'] extends 'GET' ? RouteRequestHandler<TMiddleware, {
11
61
  path: TAction['schema'] extends {
12
62
  path: any;
13
63
  } ? z.infer<TAction['schema']['path']> : MatchParams<TPath>;
@@ -1,7 +1,6 @@
1
1
  export type * from './args.js';
2
2
  export type * from './handler.js';
3
3
  export type * from './infer.js';
4
- export type * from './request.js';
5
4
  export type * from './response.js';
6
5
  export type * from './schema.js';
7
6
  export type * from './server.js';
@@ -1,19 +1,14 @@
1
1
  import type * as z from 'zod';
2
2
  import type { MutationRouteSchema, RouteSchema } from './schema.js';
3
- import type { RouteRequestFactory } from './request.js';
4
3
  type InferRouteSchemaBody<TSchema> = TSchema extends MutationRouteSchema ? TSchema extends {
5
4
  body: infer TBody;
6
5
  } ? z.infer<TBody> : unknown : never;
7
- type InferRouteArgsBody<TArgs> = TArgs extends {
8
- body?: infer TBody;
9
- } ? TBody : never;
10
6
  /**
11
- * Infer the request body type from an action schema or request factory.
7
+ * Infer the request body type from an action schema.
12
8
  *
13
9
  * @remarks HTTP action schemas can be inspected with
14
- * `InferRouteBody<typeof action.schema>`. Request factories for mutation actions
15
- * infer their `body` argument type. Schemas without a body schema infer
10
+ * `InferRouteBody<typeof action.schema>`. Schemas without a body schema infer
16
11
  * `unknown`.
17
12
  */
18
- export type InferRouteBody<T> = T extends RouteRequestFactory<any, any> ? InferRouteArgsBody<T['$args']> : T extends RouteSchema ? InferRouteSchemaBody<T> : never;
13
+ export type InferRouteBody<T> = T extends RouteSchema ? InferRouteSchemaBody<T> : never;
19
14
  export {};
@@ -1,17 +1,57 @@
1
- import type { ResponsePluginMarker, RouteSchema, Unchecked } from './schema.js';
1
+ import type { Unchecked, UncheckedError } from '../common.js';
2
+ import type { ResponsePluginMarker } from '../response.js';
3
+ import type { RouteResponseMap, RouteSchema } from './schema.js';
2
4
  /** `Response` whose `.json()` method resolves to a known payload type. */
3
5
  export type RouteResponse<TResult = any> = Response & {
4
6
  json(): Promise<TResult>;
5
7
  };
6
- /** Infer the client response type from an action schema. */
8
+ /**
9
+ * Helper: given a status-keyed response map, produce the discriminated tuple
10
+ * union for the client.
11
+ *
12
+ * Each entry becomes:
13
+ * - `$type<T>()` → `[null, T, Status]`
14
+ * - `$error<T>()` → `[T, null, Status]`
15
+ */
16
+ type InferResponseMapClient<T extends RouteResponseMap> = {
17
+ [K in keyof T & number]: T[K] extends UncheckedError<infer TError> ? [TError, null, K] : T[K] extends Unchecked<infer TSuccess> ? [null, TSuccess, K] : T[K] extends ResponsePluginMarker<infer TClient, any> ? [null, TClient, K] : never;
18
+ }[keyof T & number];
19
+ /**
20
+ * Infer the generated client action result type from an action schema.
21
+ *
22
+ * @remarks Direct JSON markers infer their payload type, plugin markers infer
23
+ * their client result type, and status-keyed response maps infer a tuple union
24
+ * of `[null, value, status]` success entries and `[error, null, status]` error
25
+ * entries.
26
+ */
7
27
  export type InferRouteResponse<T extends RouteSchema> = T extends {
8
- response: ResponsePluginMarker<infer TClient, any>;
9
- } ? TClient : T extends {
10
- response: Unchecked<infer TResponse>;
11
- } ? TResponse : void;
12
- /** Infer the non-`Response` handler result type from an action schema. */
28
+ response: infer R;
29
+ } ? R extends ResponsePluginMarker<infer TClient, any> ? TClient : R extends Unchecked<infer TResponse> ? TResponse : R extends RouteResponseMap ? InferResponseMapClient<R> : void : void;
30
+ /**
31
+ * Helper: given a status-keyed response map, produce the union of handler
32
+ * result types (success values the handler can return directly).
33
+ */
34
+ type InferResponseMapHandlerResult<T extends RouteResponseMap> = {
35
+ [K in keyof T & number]: T[K] extends Unchecked<infer TSuccess> ? TSuccess : T[K] extends ResponsePluginMarker<any, infer TRouter> ? TRouter : never;
36
+ }[keyof T & number];
37
+ /**
38
+ * Infer the non-`Response` handler result type from an action schema.
39
+ *
40
+ * @remarks For status-keyed response maps, this includes only success result
41
+ * values. Declared error responses are returned with `ctx.error(status, body)`.
42
+ */
13
43
  export type InferRouteHandlerResult<T extends RouteSchema> = T extends {
14
- response: ResponsePluginMarker<any, infer TRouter>;
15
- } ? TRouter : T extends {
16
- response: Unchecked<infer TResponse>;
17
- } ? TResponse : void;
44
+ response: infer R;
45
+ } ? R extends ResponsePluginMarker<any, infer TRouter> ? TRouter : R extends Unchecked<infer TResponse> ? TResponse : R extends RouteResponseMap ? InferResponseMapHandlerResult<R> : void : void;
46
+ /**
47
+ * Helper: given a status-keyed response map, extract error entries as a union
48
+ * of `[status, body]` pairs for typing `ctx.error(status, body)`.
49
+ */
50
+ export type InferResponseMapErrors<T extends RouteResponseMap> = {
51
+ [K in keyof T & number]: T[K] extends UncheckedError<infer TError> ? [K, TError] : never;
52
+ }[keyof T & number];
53
+ /** Extract success entries as a union of `[status, body]` pairs. */
54
+ export type InferResponseMapSuccesses<T extends RouteResponseMap> = {
55
+ [K in keyof T & number]: T[K] extends Unchecked<infer TSuccess> ? [K, TSuccess] : T[K] extends ResponsePluginMarker<any, infer TRouter> ? [K, TRouter] : never;
56
+ }[keyof T & number];
57
+ export {};
@@ -1,5 +1,5 @@
1
1
  import * as z from 'zod';
2
- import type { Unchecked } from '../common.js';
2
+ import type { Unchecked, UncheckedError } from '../common.js';
3
3
  import type { ResponsePluginMarker } from '../response.js';
4
4
  /**
5
5
  * Compile-time-only marker used by `$type<T>()` for unchecked JSON response
@@ -8,10 +8,21 @@ import type { ResponsePluginMarker } from '../response.js';
8
8
  * @remarks Application code should usually call `$type<T>()` instead of naming
9
9
  * this marker directly.
10
10
  */
11
- export type { Unchecked };
12
- export type { ResponsePluginMarker };
11
+ export type { ResponsePluginMarker, Unchecked, UncheckedError };
12
+ /** Single response marker accepted by status-keyed response maps. */
13
+ export type RouteResponseMarker = Unchecked<any> | UncheckedError<any> | ResponsePluginMarker<any, any>;
14
+ /**
15
+ * Status-keyed response map for declaring multiple response types.
16
+ *
17
+ * @remarks Numeric keys are HTTP status codes. Use `$type<T>()` or a response
18
+ * plugin marker for success responses and `$error<T>()` for declared error
19
+ * JSON responses.
20
+ */
21
+ export type RouteResponseMap = {
22
+ [status: number]: RouteResponseMarker;
23
+ };
13
24
  /** Response marker accepted by HTTP action schemas. */
14
- export type RouteResponseSchema = Unchecked<any> | ResponsePluginMarker<any, any>;
25
+ export type RouteResponseSchema = Unchecked<any> | ResponsePluginMarker<any, any> | RouteResponseMap;
15
26
  /** Schema shape for `GET` route methods. */
16
27
  export type QueryRouteSchema = {
17
28
  /** Optional Zod object used to validate path params. */
package/docs/context.md CHANGED
@@ -2,8 +2,8 @@
2
2
 
3
3
  Rouzer is for applications that want one TypeScript HTTP route tree to drive
4
4
  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.
7
7
 
8
8
  ## When to use Rouzer
9
9
 
@@ -17,9 +17,12 @@ Use Rouzer when:
17
17
  - generated clients should stay close to route definitions instead of being
18
18
  produced by a separate OpenAPI build step
19
19
 
20
- Rouzer is not a response validation library, an OpenAPI generator, or a complete
20
+ Rouzer is not a server response validator, an OpenAPI generator, or a complete
21
21
  server framework. It focuses on typed route contracts, request validation,
22
- routing, and a small client wrapper.
22
+ routing, and a small client wrapper. Response markers are type contracts; if
23
+ response data comes from an untrusted source, validate it where it enters your
24
+ server or client code instead of relying on the router to re-check handler
25
+ returns.
23
26
 
24
27
  ## Core abstractions
25
28
 
@@ -92,11 +95,47 @@ The HTTP action API models explicit operations. It does not expose the old
92
95
  method-map `ALL` fallback route shape; declare the concrete methods your client
93
96
  and server support.
94
97
 
95
- ### `$type<T>()` and `ndjson.$type<T>()`
98
+ ### Response markers and maps
96
99
 
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.
100
+ `response: $type<T>()` is a TypeScript-only marker for JSON success payloads. It
101
+ tells handlers and client action functions what payload type to expect, but
102
+ Rouzer does not validate handler return values at the server boundary. Validate
103
+ response data where it enters your system, such as an external API client,
104
+ database decoder, or UI/client boundary, when runtime integrity is required.
105
+
106
+ Use a status-keyed response map when callers need to branch on declared statuses:
107
+
108
+ ```ts
109
+ import { $error, $type } from 'rouzer'
110
+ import * as http from 'rouzer/http'
111
+
112
+ type User = { id: string; name: string }
113
+ type NotFound = { code: 'NOT_FOUND'; message: string }
114
+
115
+ export const getUser = http.get('users/:id', {
116
+ response: {
117
+ 200: $type<User>(),
118
+ 201: $type<User>(),
119
+ 404: $error<NotFound>(),
120
+ },
121
+ })
122
+ ```
123
+
124
+ Success entries use `$type<T>()` or a response plugin marker. Error entries use
125
+ `$error<T>()` and are encoded as JSON. Generated client action functions resolve
126
+ declared statuses as tuples:
127
+
128
+ - success: `[null, value, status]`
129
+ - error: `[error, null, status]`
130
+
131
+ Declared error statuses do not reject the client promise. Undeclared statuses
132
+ still go through `onJsonError` or throw the default error.
133
+
134
+ Handlers for response-map actions may return the default success value directly,
135
+ use `ctx.success(status, body)` to choose a declared success status, or use
136
+ `ctx.error(status, body)` to return a declared error status. The `ctx.error` and
137
+ `ctx.success` helpers only accept statuses and bodies declared in the response
138
+ map.
100
139
 
101
140
  `response: ndjson.$type<T>()` is a TypeScript-only marker for newline-delimited
102
141
  JSON response streams from the `rouzer/ndjson` subpath. Register
@@ -109,8 +148,8 @@ response body. Streamed items are parsed as JSON but are not validated against a
109
148
  Zod schema.
110
149
 
111
150
  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`.
151
+ functions. Actions with `response: $type<T>()` return parsed JSON typed as `T`.
152
+ Actions with a response map return the tuple union described by that map.
114
153
 
115
154
  ### Response plugins
116
155
 
@@ -121,10 +160,11 @@ matching runtime plugins. For NDJSON, those are `ndjson.$type<T>()`,
121
160
 
122
161
  The router plugin encodes non-`Response` handler results into an HTTP `Response`.
123
162
  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.
163
+ functions. Plugin markers can also be success entries in a status-keyed response
164
+ map. Rouzer validates plugin registration when routes are attached to a router or
165
+ client, so routes that use an unregistered response marker fail fast instead of
166
+ falling back to JSON. Response plugins do not automatically validate response
167
+ payloads unless the plugin itself implements validation.
128
168
 
129
169
  ### Router
130
170
 
@@ -157,6 +197,8 @@ Handlers receive a context typed from middleware plus the action schema:
157
197
  - `GET` handlers receive `ctx.path`, `ctx.query`, and `ctx.headers`
158
198
  - mutation handlers receive `ctx.path`, `ctx.body`, and `ctx.headers`
159
199
  - handlers may return a plain JSON-serializable value or a `Response`
200
+ - response-map handlers can return a default success value directly or use
201
+ `ctx.success(status, body)` and `ctx.error(status, body)`
160
202
  - `ndjson.$type<T>()` handlers return an `Iterable<T>` or `AsyncIterable<T>`
161
203
  unless they return a custom `Response`
162
204
  - plain values are returned with `Response.json(value)`
@@ -169,16 +211,16 @@ requests with an `Origin` header.
169
211
 
170
212
  ### Client
171
213
 
172
- `createClient({ baseURL, routes })` creates:
214
+ `createClient({ baseURL, routes })` creates a client tree that mirrors
215
+ `routes`, with action functions such as `client.profiles.get(args)`.
216
+ Generated action functions include:
173
217
 
174
- - `client.request(action.request(args))` for a raw `Response` when the action
175
- request factory contains the full path you want to call
176
- - `client.json(action.request(args))` for parsed JSON and default non-2xx
177
- throwing
178
- - response plugin support for generated client action functions, such as
179
- `ndjson.clientPlugin` for NDJSON response streams
180
- - a client tree that mirrors `routes`, with action functions such as
181
- `client.profiles.get(args)` when `routes` is supplied
218
+ - raw `Response` results for actions without a response schema
219
+ - parsed JSON and default non-2xx throwing for `$type<T>()` responses
220
+ - response-map support, returning `[error, value, status]` tuples for declared
221
+ statuses
222
+ - response plugin support, such as `ndjson.clientPlugin` for NDJSON response
223
+ streams
182
224
 
183
225
  Prefer an absolute `baseURL` for generated client URLs:
184
226
 
@@ -191,7 +233,8 @@ const client = createClient({
191
233
 
192
234
  Default headers can be supplied with `headers`, per-request headers are merged on
193
235
  top, and a custom `fetch` implementation can be supplied for tests or non-browser
194
- runtimes.
236
+ runtimes. The returned client exposes the original options as `clientConfig`, so
237
+ route actions named `config` remain available as `client.config(...)`.
195
238
 
196
239
  ## Lifecycle
197
240
 
@@ -205,9 +248,9 @@ runtimes.
205
248
  `fetch`.
206
249
  5. The router matches the request, validates the matched inputs, and calls the
207
250
  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.
251
+ 6. Plain handler results become JSON responses, response-map helpers choose
252
+ declared statuses, plugin handler results become plugin-encoded responses, and
253
+ explicit `Response` objects pass through unchanged.
211
254
 
212
255
  On the server, `path`, `query`, and `headers` values originate as strings. Rouzer
213
256
  coerces Zod `number` schemas with `Number(value)` and Zod `boolean` schemas from
@@ -216,9 +259,9 @@ string-coercion step.
216
259
 
217
260
  ## Common tasks
218
261
 
219
- ### Choose a client call style
262
+ ### Call client actions
220
263
 
221
- Use client action functions for normal application calls:
264
+ Use generated client action functions for application calls:
222
265
 
223
266
  ```ts
224
267
  await client.profiles.get({ path: { id: '42' } })
@@ -228,28 +271,60 @@ await client.profiles.update({
228
271
  })
229
272
  ```
230
273
 
231
- Use longhand calls when you need to choose response handling explicitly. The
232
- action request factory must include the full path you want to call, so this style
233
- is most convenient for top-level actions:
274
+ ### Handle declared error responses
275
+
276
+ Use `$error<T>()` inside a response map when an error status is part of the route
277
+ contract:
234
278
 
235
279
  ```ts
236
- export const getProfile = http.get('profiles/:id', {
237
- response: $type<Profile>(),
280
+ import { $error, $type, createClient, createRouter } from 'rouzer'
281
+ import * as http from 'rouzer/http'
282
+
283
+ type User = { id: string; name: string }
284
+ type NotFound = { code: 'NOT_FOUND'; message: string }
285
+
286
+ export const getUser = http.get('users/:id', {
287
+ response: {
288
+ 200: $type<User>(),
289
+ 404: $error<NotFound>(),
290
+ },
238
291
  })
239
- export const routes = { getProfile }
292
+ export const routes = { getUser }
240
293
 
241
- const response = await client.request(
242
- routes.getProfile.request({ path: { id: '42' } })
243
- )
294
+ createRouter().use(routes, {
295
+ getUser(ctx) {
296
+ if (ctx.path.id === 'missing') {
297
+ return ctx.error(404, {
298
+ code: 'NOT_FOUND',
299
+ message: 'User not found',
300
+ })
301
+ }
302
+ return { id: ctx.path.id, name: 'Ada' }
303
+ },
304
+ })
244
305
 
245
- const json = await client.json(
246
- routes.getProfile.request({ path: { id: '42' } })
247
- )
306
+ const client = createClient({
307
+ baseURL: 'https://example.com/api/',
308
+ routes,
309
+ })
310
+
311
+ const [error, user, status] = await client.getUser({
312
+ path: { id: 'missing' },
313
+ })
314
+
315
+ if (status === 404) {
316
+ console.log(error.message)
317
+ } else {
318
+ console.log(user.name)
319
+ }
248
320
  ```
249
321
 
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.
322
+ A complete runnable version lives in
323
+ [`examples/error-responses.ts`](../examples/error-responses.ts).
324
+
325
+ When a response map declares multiple success statuses, return a plain value for
326
+ the default success status or use `ctx.success(status, body)` to choose a
327
+ specific declared success status.
253
328
 
254
329
  ### Stream newline-delimited JSON
255
330
 
@@ -321,9 +396,9 @@ custom headers. Return a plain value for the default `Response.json(value)` path
321
396
 
322
397
  ### Customize JSON errors
323
398
 
324
- 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`.
399
+ By default, generated client action functions throw for
400
+ non-2xx responses that are not declared in a response map. If the response body
401
+ is JSON, its properties are copied onto the thrown `Error`.
327
402
 
328
403
  `onJsonError` can override that behavior. Its return value is returned from the
329
404
  response helper as-is; Rouzer does not automatically parse a returned `Response`
@@ -385,11 +460,13 @@ await client.profiles.update({
385
460
  - Export route trees from a small shared module and import that module on both
386
461
  server and client.
387
462
  - Use `rouzer/http` actions for routes that are registered with
388
- `createRouter().use(...)` or `createClient({ routes })`.
463
+ `createRouter().use(...)` or the required `createClient({ routes })` option.
389
464
  - Add Zod schemas when you need runtime guarantees; rely on inferred path params
390
465
  only when string params are sufficient.
391
466
  - Use `response: $type<T>()` for JSON endpoints that should have typed client
392
467
  action functions.
468
+ - Use response maps with `$error<T>()` when callers should handle declared error
469
+ statuses as typed data instead of exceptions.
393
470
  - Use `response: ndjson.$type<T>()` plus `ndjson.routerPlugin` and
394
471
  `ndjson.clientPlugin` for response streams where each line is a JSON value and
395
472
  the client should consume an `AsyncIterable<T>`.
@@ -400,20 +477,22 @@ await client.profiles.update({
400
477
 
401
478
  ## Constraints and gotchas
402
479
 
403
- - `$type<T>()` and `ndjson.$type<T>()` are compile-time only and do not validate
404
- response payloads or streamed items.
480
+ - `$type<T>()`, `$error<T>()`, and `ndjson.$type<T>()` are compile-time-only type
481
+ contracts. Rouzer does not re-validate handler return values at the server
482
+ boundary.
405
483
  - NDJSON support is for response streams; request bodies still use the existing
406
484
  JSON body schema path.
485
+ - Declared `$error<T>()` responses are JSON responses. Use a custom `Response`
486
+ for non-JSON error payloads.
407
487
  - Routes that use a response plugin fail fast if the matching client or router
408
488
  plugin is not registered.
409
489
  - Pathname route patterns expect an absolute client `baseURL`.
410
490
  - Resource and action keys are API names only; paths come from the pattern
411
491
  strings passed to `http.resource(...)` and action helpers.
412
- - Nested action `.request(...)` factories do not include parent resource paths;
413
- prefer client action functions for nested resources.
414
492
  - Extra `RequestInit` fields in route args, such as `signal` or `credentials`,
415
- are forwarded by `createClient`; `method`, `body`, and `headers` are reserved
416
- for Rouzer's action metadata and validated call arguments.
493
+ are forwarded by `createClient`; `method` and `body` are reserved for Rouzer's
494
+ action metadata and validated call arguments. Use route args or client defaults
495
+ for request headers.
417
496
  - The HTTP action API has no `ALL` fallback route. Declare explicit actions for
418
497
  supported methods.
419
498
  - Rouzer does not automatically set `Access-Control-Allow-Credentials`; set it in
@@ -0,0 +1,98 @@
1
+ import type { HattipHandler } from '@hattip/core'
2
+ import { $error, $type, createClient, createRouter } from 'rouzer'
3
+ import * as http from 'rouzer/http'
4
+
5
+ type User = {
6
+ id: string
7
+ name: string
8
+ }
9
+
10
+ type AuthError = {
11
+ code: 'UNAUTHORIZED'
12
+ message: string
13
+ }
14
+
15
+ type NotFoundError = {
16
+ code: 'NOT_FOUND'
17
+ message: string
18
+ }
19
+
20
+ export const getUser = http.get('users/:id', {
21
+ response: {
22
+ 200: $type<User>(),
23
+ 201: $type<User>(),
24
+ 401: $error<AuthError>(),
25
+ 404: $error<NotFoundError>(),
26
+ },
27
+ })
28
+
29
+ export const routes = { getUser }
30
+
31
+ /**
32
+ * Tiny Hattip adapter used only to keep this example self-contained. Real apps
33
+ * mount the handler with a Hattip adapter for their runtime.
34
+ */
35
+ function createLocalFetch(handler: HattipHandler): typeof fetch {
36
+ return async (input, init) => {
37
+ const request = new Request(input, init)
38
+ const response = await handler({
39
+ request,
40
+ ip: '127.0.0.1',
41
+ platform: undefined,
42
+ env() {
43
+ return undefined
44
+ },
45
+ passThrough() {},
46
+ waitUntil(promise) {
47
+ void promise
48
+ },
49
+ })
50
+
51
+ return response ?? new Response(null, { status: 404 })
52
+ }
53
+ }
54
+
55
+ export async function runErrorResponsesExample() {
56
+ const users = new Map([['42', { id: '42', name: 'Ada' }]])
57
+
58
+ const handler = createRouter({ basePath: 'api/' }).use(routes, {
59
+ getUser(ctx) {
60
+ if (ctx.path.id === 'unauthorized') {
61
+ return ctx.error(401, {
62
+ code: 'UNAUTHORIZED',
63
+ message: 'Login required',
64
+ })
65
+ }
66
+
67
+ if (ctx.path.id === 'created') {
68
+ return ctx.success(201, {
69
+ id: 'created',
70
+ name: 'Grace',
71
+ })
72
+ }
73
+
74
+ const user = users.get(ctx.path.id)
75
+ if (!user) {
76
+ return ctx.error(404, {
77
+ code: 'NOT_FOUND',
78
+ message: 'User not found',
79
+ })
80
+ }
81
+
82
+ return user
83
+ },
84
+ })
85
+
86
+ const client = createClient({
87
+ baseURL: 'https://example.test/api/',
88
+ routes,
89
+ fetch: createLocalFetch(handler),
90
+ })
91
+
92
+ const found = await client.getUser({ path: { id: '42' } })
93
+ const created = await client.getUser({ path: { id: 'created' } })
94
+ const missing = await client.getUser({ path: { id: 'missing' } })
95
+ const unauthorized = await client.getUser({ path: { id: 'unauthorized' } })
96
+
97
+ return { found, created, missing, unauthorized }
98
+ }