Skip to content

RFC: evolving defineValidatedHandler / defineRoute into a richer validated-routing core and types #1437

Description

@sandros94

Describe the feature

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, body and response — 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 today
defineRouteHandler({
  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 (:idnumber), 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.
  • onValidationError cascade: app → route → method override, per source (builds directly on feat: customizable validation errors #1146).

Stays downstream (listed for completeness, not proposed for core):

  • Wire-shape response typing (Serialize, Datestring — 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.
const handler = 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 handler
handler["~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 cost
type Methods = InferMethods<typeof handler>
//   → { 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?

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions