You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
I've been building h3-route-tools on top of h3 v2 — essentially a playground for a fully-typed, validated-routing experience. A good chunk of it grew straight out of the Standard Schema work (#963/#964, #1146) as well as @productdevbook implementations (#1143, #1237 and #1339), and by now a few pieces feel like they might belong upstream rather than living in a side package. I'd like to open the discussion before we're out of the rc window, so there's still room for the breaking changes some of this would imply.
Scope up front, so nobody panics at the diff: I'm not proposing to move the typed-fetch client, the codegen, or the Nitro module into core, and OpenAPI I'd specifically want to stay an opt-in plugin (happy for it to become a first-party one down the line). What I think is worth discussing for core is the validated-handler surface itself: params + response validation, per-method dispatch, uniform async validation and the contract sidecar (definitions and types).
Core already ships defineValidatedHandler (experimental), defineRoute, and the getValidated* helpers. The h3-route-tools versions are supersets of those same APIs — which is exactly why the diff looks scary at a glance. This issue is my attempt to make it legible.
On async validation
The one concrete gap I keep bumping into: the declarative defineValidatedHandler path can't run async schemas for query/headers. syncValidate throws Asynchronous validation is not supported for ${source} the moment a Standard Schema returns a Promise — only body is awaited (lazily, via .json()), and params/response aren't validated there at all. The standalone getValidated* helpers do await, so the capability is already in core — it just isn't wired through the handler.
In h3-route-tools everything runs through a single async validateData, so async refinements work uniformly across params, query, headers, bodyandresponse — e.g. an async uniqueness/remote lookup on a query param, or a DB-backed check inside a response schema. That uniformity is the piece I'd most like to see land upstream.
// works in h3-route-tools; the query path throws in core's defineValidatedHandler todaydefineRouteHandler({params: v.object({id: v.pipe(v.string(),v.transform(Number))}),get: {validate: {query: v.pipeAsync(v.object({slug: v.string()}),v.checkAsync(isUnique)),response: User,// validated too — 500 on contract breach, not 400},handler: (event)=>getUser(event.context.params.id),},})
What h3-route-tools does that core doesn't (yet)
Core candidates — the bits I think are worth discussing for upstream:
params validation as a first-class, route-level slot — schema-coerced (:id → number), inferred into the handler's event.context.params.
response validation — a bare schema (sugars to 200) or a status-code map ({ 200: User, 404: NotFound }); a failure is a 500 (server-side contract breach), never a 400.
Fully async validation across every source; no sync-only restriction anywhere.
Multi-method dispatch from one definition (defineRouteHandler / defineRoute): per-method validate inference in a single object literal, auto-HEAD (runs GET, drops the body), auto-OPTIONS (Allow), and 405 with a correct Allow computed across every handler on the path.
Content-type-aware body: media-type maps ({ "application/json": A, "multipart/form-data": B }), 415 when nothing matches, and raw-stream passthrough for bodies that shouldn't be buffered.
Per-status streamed responses (doc-only, validation skipped), with a guard that fails loudly if a schema-validated status accidentally returns a stream.
Stays downstream (listed for completeness, not proposed for core):
Wire-shape response typing (Serialize, Date → string — the same round-trip type Nitro uses) and the typed fetch client / route-contract inference (InferRoutes, H3Typed).
OpenAPI generation from the same contracts — opt-in plugin, ideally first-party in the future.
The Nitro module (types $fetch, method-lock checks, OpenAPI overlay).
The contract sidecar (~routeDef & friends)
None of the three define*only return a plain handler — each carries its own validation contract as a sidecar, both at runtime and in the type system, and that's really the connective tissue the whole "stays downstream" list hangs off:
Runtime stamps (real values): defineRouteHandler → ~routeDef, defineValidatedHandler → ~validatedDef, both with ~options; defineRoute carries a ~routePlugin brand. OpenAPI generation and the Nitro module read these to build docs and type $fetch straight from the definition — the author never re-declares anything.
Type-only stamps (phantom, never present at runtime): ~inferMethods / ~inferEndpoint / ~inferRoute, read through InferMethods / InferRoutes to reconstruct the typed route map for the fetch client at zero runtime cost.
consthandler=defineRouteHandler({params: v.object({id: v.pipe(v.string(),v.transform(Number))}),get: {validate: {response: User},handler: (e)=>getUser(e.context.params.id)},})// runtime: the resolved contract travels with the handlerhandler["~routeDef"].get?.validate?.response// → the `User` schema, no re-declaration// (exactly what the OpenAPI generator and the Nitro module read off it)// type-level: the phantom stamp reconstructs the typed route map at zero runtime costtypeMethods=InferMethods<typeofhandler>// → { get: { params: { id: number }; response: User; … }}
The ~ prefix is a deliberate nod to ~standard — "tooling-facing, not user API". The open question for core: is there interest in blessing a convention like this, a validated handler exposing its resolved contract as an introspectable property? If core did, a first-party OpenAPI plugin (or anyone else) could read one normalized shape instead of every tool reinventing its own extraction. That, more than any single feature above, is what makes the one-definition-as-source-of-truth story work.
What I'd like to figure out
Is there appetite to grow core's defineValidatedHandler/defineRoute toward params + response + uniform async, or is that firmly plugin/community territory?
If yes, how much of it can be additive vs. genuinely breaking (params/response are additive; making query/headers async in the handler is the breaking one)?
Entry points: h3-route-tools keeps two shapes on purpose — defineRouteHandler returns a self-dispatching EventHandlerWithFetch (mount it directly, and it doubles as a valid Nitro route default-export), while defineRoute returns a structured H3Plugin for app.register(). Core's defineRoute today is the single-method plugin; its multi-method, Nitro-compatible sibling is what I've called defineRouteHandler. They could converge — a little wiring and each adapts to both h3 and Nitro — but the internals and scope differ enough that I've kept them distinct. Worth agreeing on the split (and the names) before rc closes IMO.
Personal considerations
None of this is urgent enough to block the release — but the breaking subset (async query/headers in the handler) only fits cleanly while we're still in rc, hence opening it now rather than later. I'm happy to do the implementation, as always.
One thing I'm deliberately leaving out for now: I'd love to eventually give defineJsonRpcHandler (#1180) the same schema-validation treatment — but that only makes sense to bring up once/if the core bits above are actually wanted. Flagging it so it's on the radar, not proposing it here.
I've also created H3Typed which extends H3, but provides the same type and DX of the above functions but in the object form of each .get/.post/..etc, as well as an added .route one. Then a typed client (and codegen) simply requires as input the typeof app (thanks to the sidecars explained above).
Additional info (and context for agents like @pi0x)
Some of the original rationale behind this is that in h3 we have great coverage of anything that is low-level, I really cannot think of something missing that isn't duable. But we are lacking a bit on what is mid-to-high level coding.
That type of coding that internally is slightly opinionated such as Elysia's schema validation, openapi and type modeling. Which I'm finding that agents and new developers have an easier time to understand and use because everything is strongly typed, as well as they are not forced to do everything manually in the handler's wild-west (handler: (event) => { ALL_DONE_MANUALLY }), which seems to scare and demotivate more than I could think off.
Additional information
Would you be willing to help implement this feature?
Describe the feature
I've been building
h3-route-toolson top of h3 v2 — essentially a playground for a fully-typed, validated-routing experience. A good chunk of it grew straight out of the Standard Schema work (#963/#964, #1146) as well as @productdevbook implementations (#1143, #1237 and #1339), and by now a few pieces feel like they might belong upstream rather than living in a side package. I'd like to open the discussion before we're out of the rc window, so there's still room for the breaking changes some of this would imply.Scope up front, so nobody panics at the diff: I'm not proposing to move the typed-fetch client, the codegen, or the Nitro module into core, and OpenAPI I'd specifically want to stay an opt-in plugin (happy for it to become a first-party one down the line). What I think is worth discussing for core is the validated-handler surface itself:
params+responsevalidation, per-method dispatch, uniform async validation and the contract sidecar (definitions and types).Core already ships
defineValidatedHandler(experimental),defineRoute, and thegetValidated*helpers. The h3-route-tools versions are supersets of those same APIs — which is exactly why the diff looks scary at a glance. This issue is my attempt to make it legible.On async validation
The one concrete gap I keep bumping into: the declarative
defineValidatedHandlerpath can't run async schemas forquery/headers.syncValidatethrowsAsynchronous validation is not supported for ${source}the moment a Standard Schema returns aPromise— onlybodyis awaited (lazily, via.json()), andparams/responsearen't validated there at all. The standalonegetValidated*helpers do await, so the capability is already in core — it just isn't wired through the handler.In h3-route-tools everything runs through a single async
validateData, so async refinements work uniformly acrossparams,query,headers,bodyandresponse— e.g. an async uniqueness/remote lookup on a query param, or a DB-backed check inside a response schema. That uniformity is the piece I'd most like to see land upstream.What h3-route-tools does that core doesn't (yet)
Core candidates — the bits I think are worth discussing for upstream:
paramsvalidation as a first-class, route-level slot — schema-coerced (:id→number), inferred into the handler'sevent.context.params.responsevalidation — a bare schema (sugars to200) or a status-code map ({ 200: User, 404: NotFound }); a failure is a500(server-side contract breach), never a400.defineRouteHandler/defineRoute): per-methodvalidateinference in a single object literal, auto-HEAD (runs GET, drops the body), auto-OPTIONS (Allow), and405with a correctAllowcomputed across every handler on the path.{ "application/json": A, "multipart/form-data": B }),415when nothing matches, and raw-stream passthrough for bodies that shouldn't be buffered.onValidationErrorcascade: app → route → method override, per source (builds directly on feat: customizable validation errors #1146).Stays downstream (listed for completeness, not proposed for core):
Serialize,Date→string— the same round-trip type Nitro uses) and the typed fetch client / route-contract inference (InferRoutes,H3Typed).$fetch, method-lock checks, OpenAPI overlay).The contract sidecar (
~routeDef& friends)None of the three
define*only return a plain handler — each carries its own validation contract as a sidecar, both at runtime and in the type system, and that's really the connective tissue the whole "stays downstream" list hangs off:defineRouteHandler→~routeDef,defineValidatedHandler→~validatedDef, both with~options;defineRoutecarries a~routePluginbrand. OpenAPI generation and the Nitro module read these to build docs and type$fetchstraight from the definition — the author never re-declares anything.~inferMethods/~inferEndpoint/~inferRoute, read throughInferMethods/InferRoutesto reconstruct the typed route map for the fetch client at zero runtime cost.The
~prefix is a deliberate nod to~standard— "tooling-facing, not user API". The open question for core: is there interest in blessing a convention like this, a validated handler exposing its resolved contract as an introspectable property? If core did, a first-party OpenAPI plugin (or anyone else) could read one normalized shape instead of every tool reinventing its own extraction. That, more than any single feature above, is what makes the one-definition-as-source-of-truth story work.What I'd like to figure out
defineValidatedHandler/defineRoutetowardparams+response+ uniform async, or is that firmly plugin/community territory?defineRouteHandlerreturns a self-dispatchingEventHandlerWithFetch(mount it directly, and it doubles as a valid Nitro route default-export), whiledefineRoutereturns a structuredH3Pluginforapp.register(). Core'sdefineRoutetoday is the single-method plugin; its multi-method, Nitro-compatible sibling is what I've calleddefineRouteHandler. They could converge — a little wiring and each adapts to both h3 and Nitro — but the internals and scope differ enough that I've kept them distinct. Worth agreeing on the split (and the names) before rc closes IMO.Personal considerations
None of this is urgent enough to block the release — but the breaking subset (async query/headers in the handler) only fits cleanly while we're still in rc, hence opening it now rather than later. I'm happy to do the implementation, as always.
One thing I'm deliberately leaving out for now: I'd love to eventually give
defineJsonRpcHandler(#1180) the same schema-validation treatment — but that only makes sense to bring up once/if the core bits above are actually wanted. Flagging it so it's on the radar, not proposing it here.I've also created H3Typed which extends H3, but provides the same type and DX of the above functions but in the object form of each
.get/.post/..etc, as well as an added.routeone. Then a typed client (and codegen) simply requires as input thetypeof app(thanks to the sidecars explained above).Additional info (and context for agents like @pi0x)
Some of the original rationale behind this is that in h3 we have great coverage of anything that is low-level, I really cannot think of something missing that isn't duable. But we are lacking a bit on what is mid-to-high level coding.
That type of coding that internally is slightly opinionated such as Elysia's schema validation, openapi and type modeling. Which I'm finding that agents and new developers have an easier time to understand and use because everything is strongly typed, as well as they are not forced to do everything manually in the handler's wild-west (
handler: (event) => { ALL_DONE_MANUALLY }), which seems to scare and demotivate more than I could think off.Additional information