h3-route-tools
v0.1.1
Published
Experimental library to generate typed routes for H3
Maintainers
Readme
h3-route-tools
Type-first routes for h3 v2 — validated params/body/query/response, a typed fetch client, and OpenAPI 3.1 — for plain h3 and nitro v3.
[!NOTE] This is highly experimental with the main goal of exploring a fully type-first route design with a typed fetch client. The idea is based on the work done by productdevbook on the upstream h3#1143 and h3#1237.
Routes are validated with any Standard Schema validator (zod, valibot, …). The examples below use valibot.
Plain h3
Define routes with per-method validation. The validated params/query/headers are written — typed — to event.context (and mirrored read-only on event.validated); the response is validated too, and failures return 400 automatically.
import { serve } from "srvx";
import { H3Typed } from "h3-route-tools";
import * as v from "valibot";
const app = new H3Typed().route({
route: "/posts/:id",
params: v.object({ id: v.pipe(v.string(), v.toNumber()) }),
get: {
validate: { response: v.object({ id: v.number(), title: v.string() }) },
handler: (event) => ({ id: event.context.params.id, title: "Hello" }),
},
post: {
validate: {
body: v.object({ title: v.string() }),
response: v.object({ id: v.number() }),
},
handler: (event) => ({ id: event.context.params.id }),
},
});
serve(app);Typed fetch client
createTypedFetch<typeof app> infers the route, method, params, body, and response. Address a route by its pattern:
import { createTypedFetch } from "h3-route-tools";
// Hit the app directly, or pass `{ baseURL: "https://api.example.com" }` to use global fetch.
const api = createTypedFetch<typeof app>({ fetch: app.request });
const res = await api("/posts/:id", { method: "post", params: { id: 1 }, body: { title: "Hi" } });
const created = await res.json(); // typed: { id: number }Requests are serialized to match what the server decodes: params are substituted into the pattern, a Date param/query value goes out as an ISO string, and an array as repeated keys (?tags=a&tags=b). res.json() is typed as the wire shape: a v.date() / z.date() response field comes back as string (that is what JSON gives you), not Date.
Custom validation errors
Out of the box, a request that fails validation is a 400 and a bad handler response a 500, both carrying the schema issues. To shape them yourself there are only two things to decide:
- What failed — every hook receives
{ source, issues, event }, wheresourceis"params" | "query" | "headers" | "body" | "response". You write one function and branch onsource. - Where to hook it — the same
(failure) => ErrorDetails | voidruns at three scopes that cascade method → route → app (the narrower one wins). ReturnErrorDetailsto override, or nothing to fall back to the default.
That's the whole model: one function shape, applied at the scope you need. A complete app:
import { serve } from "srvx";
import { H3Typed } from "h3-route-tools";
import * as v from "valibot";
const app = new H3Typed({
// App-wide default: one consistent error envelope for every route.
// (Named `onValidationError` so it doesn't shadow h3's own catch-all `onError`.)
onValidationError: ({ source, issues, event }) => ({
status: source === "response" ? 500 : 422,
message: `${source} validation failed`,
data: {
source,
requestId: event.req.headers.get("x-request-id"),
issues: issues.map((i) => ({ path: i.path, message: i.message })),
},
}),
}).route({
route: "/posts/:id",
params: v.object({ id: v.pipe(v.string(), v.toNumber()) }),
post: {
validate: {
body: v.object({ title: v.string() }),
response: v.object({ id: v.number(), title: v.string() }),
},
handler: async (event) => {
const { title } = await event.req.json();
return { id: event.context.params.id, title };
},
},
});
serve(app);curl -X POST localhost:3000/posts/abc -d '{"title":"hi"}'
# 422 { "message": "params validation failed", "data": { "source": "params", … } }
curl -X POST localhost:3000/posts/1 -H 'content-type: application/json' -d '{"title":42}'
# 422 { "message": "body validation failed", "data": { "source": "body", … } }Choosing a scope
onValidationError lives in the same place at every level — the config/def object — so it never hides in a forgettable second argument:
| Scope | When | Where |
| ------------ | ------------------------------------ | ------------------------------------- |
| Defaults | you just want 400/500 + issues | do nothing |
| App | one envelope for the whole app | new H3Typed({ onValidationError }) |
| Route | special-case a single route | .route({ onValidationError, … }) |
| Method | special-case one method of a route | get: { onValidationError, handler } |
// All three at once — method beats route beats app:
new H3Typed({ onValidationError: appDefault }).route({
route: "/x",
onValidationError: forThisRoute,
get: { onValidationError: justForGet, handler },
});In Nitro (or anywhere without an H3Typed app), defineRouteHandler takes the same onValidationError in its definition object — there's no app level, so set it per route file (or per method):
export default defineRouteHandler({
onValidationError: ({ source, issues }) => ({ status: 422, data: { source, issues } }),
get: { validate: { response: User }, handler },
});One thing to keep in mind: a response failure always stays 500 (it's a server-side contract breach — your message/data are used, the status is not).
onValidationErrorshapes the runtime response only. The OpenAPI error-response schema is separate — set it (or turn it off) globally viadefineOpenAPI({ errors })if you change the envelope and want the document to match.
Nitro v3
Add the module and write file routes whose default export is a defineRouteHandler:
// nitro.config.ts
import { defineConfig } from "nitro";
export default defineConfig({
modules: ["h3-route-tools/nitro"],
serverDir: "./",
compatibilityDate: "2026-06-10",
});// routes/posts/[id].ts
import { defineRouteHandler } from "h3-route-tools";
import * as v from "valibot";
export default defineRouteHandler({
params: v.object({ id: v.pipe(v.string(), v.toNumber()) }),
get: {
validate: { response: v.object({ id: v.number(), title: v.string() }) },
handler: (event) => ({ id: event.context.params.id, title: "Hello" }),
},
post: {
validate: { body: v.object({ title: v.string() }), response: v.object({ id: v.number() }) },
handler: (event) => ({ id: event.context.params.id }),
},
});The same handler serves every declared method (it self-dispatches), so a multi-method handler goes in a catch-all file (routes/posts/[id].ts). The module:
- types nitro's
InternalApi(what$fetchand a$Fetch-typed client read) per method, from each route's contract; - fails the build if a multi-method handler sits in a method-locked file (
posts.get.ts), where the other methods would be silently unreachable; - enriches nitro's OpenAPI document (below).
For a single method, defineValidatedHandler is a leaner primitive — method-agnostic, no self-dispatch — so it drops straight into a method-locked file:
// routes/posts/[id].get.ts
import { defineValidatedHandler } from "h3-route-tools";
import * as v from "valibot";
export default defineValidatedHandler({
params: v.object({ id: v.pipe(v.string(), v.toNumber()) }),
validate: { response: v.object({ id: v.number(), title: v.string() }) },
handler: (event) => ({ id: event.context.params.id, title: "Hello" }),
});Typed client in nitro
Use nitro's own $fetch (response typing only), or createTypedFetch to also type params/body/query — fed a type-only route map (no runtime import, no client-bundle cost):
import { createTypedFetch } from "h3-route-tools";
type Routes = {
"/posts/:id": typeof import("../routes/posts/[id]").default;
};
export const api = createTypedFetch<Routes>();
// api("/posts/:id", { method: "get", params: { id: 7 } })OpenAPI
Enable nitro's OpenAPI. The module merges your routes' contracts into nitro's document — keeping nitro's entries for plain/legacy routes — and nitro's Scalar (/_scalar) and Swagger (/_swagger) UIs render the result:
// nitro.config.ts
export default defineConfig({
modules: ["h3-route-tools/nitro"],
serverDir: "./",
experimental: { openAPI: true },
openAPI: { production: "runtime" }, // also serve it from the built server
});Per-operation prose (defineOperation) and per-schema overrides (defineSchema) travel with the handlers, so they appear here too — see OpenAPI below.
OpenAPI
OpenAPI lives in its own opt-in entry, h3-route-tools/openapi — the core stays document-agnostic. With H3Typed, pass an openapi block and the document is served (default /openapi.json):
import { H3Typed } from "h3-route-tools";
const app = new H3Typed({
openapi: { info: { title: "API", version: "1.0.0" } },
}).route(/* … */);
// GET /openapi.json → the generated 3.1 documentFor a plain H3 app, register the plugin over your defineRoute handlers:
import { defineOpenAPI } from "h3-route-tools/openapi";
app.register(defineOpenAPI({ info: { title: "API", version: "1.0.0" } }));The document is built from each route's schemas — requests from a schema's input, responses from its output (so a coercion like v.pipe(v.string(), v.toNumber()) on a param shows as string; only the accepted input reaches the wire) — with 400/415/500 responses added where relevant.
Customizing
Customization is central, in three tiers by scope.
Global — document (a full replacement value, or a (doc, ctx) => doc transform for what the generator can't infer, like servers or security schemes) and errors (opt out of / override the auto error responses):
new H3Typed({
openapi: {
info: { title: "API", version: "1.0.0" },
errors: false,
document: (doc) => ({ ...doc, servers: [{ url: "https://api.example.com" }] }),
},
});Per operation — prose (summary, tags, deprecated, …) via defineOperation on a route/method meta.openapi; it merges over the generated operation:
import { defineOperation } from "h3-route-tools/openapi";
defineRoute({
route: "/posts",
get: {
meta: { openapi: defineOperation({ summary: "List posts", tags: ["posts"] }) },
handler,
},
});Per schema — override a schema's emitted JSON Schema with defineSchema (which also assigns $id, lifting the schema into components). This is how you describe a shape the library can't infer:
import { defineSchema } from "h3-route-tools/openapi";
// a Date has no JSON Schema type — give it the wire form:
const When = defineSchema(v.date(), { jsonSchema: { type: "string", format: "date-time" } });defineSchema wraps a whole slot (a body/response/params schema), not a nested field. For a nested field use your validator's metadata — zod's .meta({ … }) merges into the output at any depth — or patch the assembled slot with the function form, defineSchema(schema, { jsonSchema: (auto) => ({ ...auto, … }) }).
JSON Schema support
Validation and TypeScript types work with any Standard Schema validator. OpenAPI generation needs one thing more: the schema must expose a JSON Schema through ~standard.jsonSchema. That's the only thing checked — no per-library special-casing.
- zod (v4) — native; works out of the box, and
.meta()merges arbitrary keywords into the output. - valibot — not on its own; wrap schemas with
@valibot/to-json-schema'stoStandardJsonSchema(). Its adapter drops arbitrary metadata, so describe un-inferrable shapes withdefineSchema(which works even on a bare valibot schema).
A Date, a transform's output, and other un-representable types emit {} — an empty, permissive schema — leaving validation and TS types untouched, only the document. Give them a shape with defineSchema / .meta() / the document hook above.
License
MIT
