Skip to content

feat: enhance validation capabilities for routes and handlers#1237

Open
productdevbook wants to merge 5 commits into
mainfrom
feat/validation-enhancements
Open

feat: enhance validation capabilities for routes and handlers#1237
productdevbook wants to merge 5 commits into
mainfrom
feat/validation-enhancements

Conversation

@productdevbook

@productdevbook productdevbook commented Nov 3, 2025

Copy link
Copy Markdown
Member

Summary

cc: #1088

This PR enhances H3's validation capabilities with comprehensive type-safe validation for routes and handlers:

  • Route params validation: Added support for validating route parameters (e.g., /users/:id) with full type inference
  • Response validation: Server-side response validation to ensure handlers return correctly shaped data
  • Enhanced defineValidatedHandler: Now supports params and response schemas in addition to existing body, headers, and query validation
  • Enhanced defineRoute: Full type-safe validation with proper TypeScript overloads for better DX
  • New utilities: Exported syncValidate for synchronous validation and validateResponse for response validation
  • Comprehensive tests: Added full test coverage including integration tests demonstrating complete validation workflows

Key Features

1. Params Validation

const handler = defineValidatedHandler({
  validate: {
    params: z.object({ id: z.string().uuid() })
  },
  handler: (event) => {
    // event.context.params is typed as { id: string }
    const id = event.context.params.id;
    return { userId: id };
  }
});

2. Response Validation

const route = defineRoute({
  method: 'POST',
  route: '/api/users',
  validate: {
    response: z.object({ id: z.string(), name: z.string() })
  },
  handler: async (event) => {
    // Return type is validated and type-checked
    return { id: '123', name: 'John' };
  }
});

3. Complete Validation

defineRoute({
  method: 'POST',
  route: '/api/users/:id',
  validate: {
    params: z.object({ id: z.string().uuid() }),
    query: z.object({ include: z.string().optional() }),
    body: z.object({ name: z.string(), email: z.string().email() }),
    response: z.object({ id: z.string(), name: z.string(), email: z.string() })
  },
  handler: async (event) => {
    // All types are inferred: params, query, body, and return type
    const { id } = event.context.params;
    const body = await event.req.json();
    return { id, name: body.name, email: body.email };
  }
});

Test Plan

  • Unit tests for params validation in defineValidatedHandler
  • Unit tests for response validation in defineValidatedHandler
  • Integration tests for defineRoute with all validation schemas
  • Error handling tests for invalid params, query, body, headers, and response
  • Type inference tests demonstrating full type safety
  • Existing tests continue to pass

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Added route parameter validation with type-safe inference
    • Introduced response validation to ensure type-safe API responses
    • Extended route definitions with built-in validation for params, body, query, and responses
    • Added custom error handling for validation failures
  • Tests

    • Added comprehensive test coverage for validation workflows

- Added support for response validation in defineValidatedHandler
- Introduced syncValidate function for synchronous validation
- Updated defineRoute to include comprehensive validation schemas
- Expanded tests for full validation type inference and error handling
@productdevbook productdevbook requested a review from pi0 as a code owner November 3, 2025 18:06
@coderabbitai

coderabbitai Bot commented Mar 8, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

Adds runtime and compile-time validation across request lifecycle: params, query, headers, body, and responses. Introduces typed route definitions and validated handler APIs, new validation utilities, and extensive tests exercising validation behavior and type inference.

Changes

Cohort / File(s) Summary
Core validation logic
src/handler.ts, src/utils/internal/validate.ts
Added runtime validation flow: syncValidate, validateResponse, params validation assigning event.context.params, request/URL validation, optional response validation, and converted internal handler to async.
Typed routing & API surface
src/utils/route.ts, src/index.ts
Introduced generic RouteDefinition / RouteValidation types, ValidatedRouteEvent, overloaded defineRoute (with and without validation), and re-exported route validation types from src/index.ts.
Tests — integration & unit
test/full-validation-example.test.ts, test/handler.test.ts, test/route.test.ts, test/unit/types.test-d.ts
Added extensive tests covering params/query/headers/body/response validation, error handling (including custom onError), and type inference for defineRoute and defineValidatedHandler.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Suggested labels

enhancement

Suggested reviewers

  • pi0

Poem

🐇 I hopped through code and left a trace,
Params and bodies all in place,
Responses checked before they roam,
Typed routes now guide them home,
A carrot for tests — they pass with grace! 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and directly summarizes the main enhancement: adding validation capabilities for routes and handlers, which is the core objective of all changes across handler, route utilities, and extensive test coverage.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feat/validation-enhancements

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@pkg-pr-new

pkg-pr-new Bot commented Mar 8, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/h3@1237

commit: bb40399

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/handler.ts`:
- Around line 119-124: The params validation call omits the onError handler so
parameter validation doesn't use custom error formatting; update the call in
handler where syncValidate("params", params, def.validate!.params) is used to
pass the same onError callback used for body/query/headers (propagate the
onError from the route definition or surrounding context into syncValidate),
ensuring event.context.params is assigned from syncValidate("params", params,
def.validate!.params, onError) and that defineRoute's validation path forwards
that onError consistently.

In `@src/utils/internal/validate.ts`:
- Around line 214-219: The thrown 500 includes validator internals
(error?.message and error?.data) which leaks server-side schema details;
instead, in the throw new HTTPError call remove error?.message and error?.data
and replace them with a generic statusText/message like "Response validation
failed" (keep status: 500) and log the original validator error privately (e.g.,
using the existing logger) before throwing; update the throw site that
constructs the HTTPError (the throw new HTTPError({...}) block and any
surrounding validate/response-validation function) to avoid returning validator
internals to clients while preserving internal logging for diagnostics.

In `@src/utils/route.ts`:
- Around line 40-54: The example route's handler that uses await needs to be
declared async: locate the example route object that defines the handler
property (the handler: (event) => { ... } example) and change its declaration to
handler: async (event) => { ... } so await inside the function is valid; ensure
the function body and returned types remain unchanged (the change is only adding
the async keyword to the handler).
- Around line 133-155: The example's handler is using await without being async
and the route registration uses app.use instead of app.register; update the
example so the handler function passed to defineRoute is declared async (e.g.,
async (event) => { ... }) so await is valid, and replace the app.use(userRoute)
call with app.register(userRoute) to match the documented registration API;
ensure the symbol names defineRoute, handler arrow function, and userRoute are
adjusted accordingly in the snippet.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 05b6bd35-ebb3-479e-b7d2-2ebd01d071d2

📥 Commits

Reviewing files that changed from the base of the PR and between 52c82e1 and db67cf7.

📒 Files selected for processing (7)
  • src/handler.ts
  • src/index.ts
  • src/utils/internal/validate.ts
  • src/utils/route.ts
  • test/full-validation-example.test.ts
  • test/handler.test.ts
  • test/route.test.ts

Comment thread src/handler.ts Outdated
Comment on lines +214 to +219
throw new HTTPError({
cause: error,
status: 500,
statusText: "Response validation failed",
message: error?.message || "Response validation failed",
data: error?.data,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't return validator internals from 500 responses.

Response-schema mismatches are server bugs, not client input errors. Forwarding error?.message and error?.data here exposes internal field names and validation rules in a public 500 payload without giving the caller anything actionable.

Suggested fix
   } catch (error: any) {
     throw new HTTPError({
       cause: error,
       status: 500,
       statusText: "Response validation failed",
-      message: error?.message || "Response validation failed",
-      data: error?.data,
+      message: "Response validation failed",
     });
   }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
throw new HTTPError({
cause: error,
status: 500,
statusText: "Response validation failed",
message: error?.message || "Response validation failed",
data: error?.data,
throw new HTTPError({
cause: error,
status: 500,
statusText: "Response validation failed",
message: "Response validation failed",
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/internal/validate.ts` around lines 214 - 219, The thrown 500
includes validator internals (error?.message and error?.data) which leaks
server-side schema details; instead, in the throw new HTTPError call remove
error?.message and error?.data and replace them with a generic
statusText/message like "Response validation failed" (keep status: 500) and log
the original validator error privately (e.g., using the existing logger) before
throwing; update the throw site that constructs the HTTPError (the throw new
HTTPError({...}) block and any surrounding validate/response-validation
function) to avoid returning validator internals to clients while preserving
internal logging for diagnostics.

Comment thread src/utils/route.ts Outdated
Comment on lines +40 to +54
handler: (
event: H3Event<
EventHandlerRequest & {
body: V["body"] extends StandardSchemaV1 ? InferOutput<V["body"]> : unknown;
query: V["query"] extends StandardSchemaV1
? StringHeaders<InferOutput<V["query"]>>
: Partial<Record<string, string>>;
routerParams: V["params"] extends StandardSchemaV1
? InferOutput<V["params"]>
: Record<string, string>;
}
>,
) =>
| (V["response"] extends StandardSchemaV1 ? InferOutput<V["response"]> : unknown)
| Promise<V["response"] extends StandardSchemaV1 ? InferOutput<V["response"]> : unknown>;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cd /repo && find . -type f -name "route.ts" -o -name "index.ts" | head -20

Repository: h3js/h3

Length of output: 106


🏁 Script executed:

cd /repo && wc -l src/utils/route.ts src/index.ts

Repository: h3js/h3

Length of output: 106


🏁 Script executed:

cd /repo && cat -n src/utils/route.ts | head -160

Repository: h3js/h3

Length of output: 106


🏁 Script executed:

cd /repo && cat -n src/index.ts

Repository: h3js/h3

Length of output: 106


🏁 Script executed:

find . -type f -name "route.ts" -o -name "index.ts" 2>/dev/null | grep -E "(route\.ts|index\.ts)$"

Repository: h3js/h3

Length of output: 86


🏁 Script executed:

wc -l src/utils/route.ts src/index.ts 2>/dev/null || echo "Files not found, listing directory structure..."

Repository: h3js/h3

Length of output: 103


🏁 Script executed:

ls -la src/utils/ && ls -la src/ 2>/dev/null || find . -name "route.ts" -type f

Repository: h3js/h3

Length of output: 2279


🏁 Script executed:

cat -n src/utils/route.ts

Repository: h3js/h3

Length of output: 6883


🏁 Script executed:

cat -n src/index.ts

Repository: h3js/h3

Length of output: 5568


Documentation example handler must be declared async to use await.

The example at lines 163-168 uses await on line 166 but the handler function is not declared as async. Change handler: (event) => { to handler: async (event) => {.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/route.ts` around lines 40 - 54, The example route's handler that
uses await needs to be declared async: locate the example route object that
defines the handler property (the handler: (event) => { ... } example) and
change its declaration to handler: async (event) => { ... } so await inside the
function is valid; ensure the function body and returned types remain unchanged
(the change is only adding the async keyword to the handler).

Comment thread src/utils/route.ts
Comment on lines 133 to 155
* @example
* ```js
* import { z } from "zod";
*
* const userRoute = defineRoute({
* method: 'POST',
* route: '/api/users/:id',
* validate: {
* query: z.object({ id: z.string().uuid() }),
* params: z.object({ id: z.string().uuid() }),
* query: z.object({ include: z.string().optional() }),
* body: z.object({ name: z.string() }),
* response: z.object({ id: z.string(), name: z.string() }),
* },
* handler: (event) => {
* return { success: true };
* // event.context.params, await event.req.json(), and return value are all typed!
* const { id } = event.context.params;
* const body = await event.req.json();
* return { id, name: body.name };
* }
* });
*
* app.register(userRoute);
* app.use(userRoute);
* ```

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

find . -name "route.ts" -type f | head -20

Repository: h3js/h3

Length of output: 71


🏁 Script executed:

cat -n src/utils/route.ts | head -200

Repository: h3js/h3

Length of output: 6883


Fix the example code before publishing it.

The example has two issues:

  1. The handler on line 163 uses await without async — this is a syntax error
  2. Line 171 uses app.use(userRoute) but the API documentation states the route should be registered with app.register() (see line 145)
Suggested fix
- *    handler: (event) => {
+ *    handler: async (event) => {
 *      // event.context.params, await event.req.json(), and return value are all typed!
 *      const { id } = event.context.params;
 *      const body = await event.req.json();
 *      return { id, name: body.name };
 *    }
 * });
 *
- * app.use(userRoute);
+ * app.register(userRoute);
 * ```
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
* @example
* ```js
* import { z } from "zod";
*
* const userRoute = defineRoute({
* method: 'POST',
* route: '/api/users/:id',
* validate: {
* query: z.object({ id: z.string().uuid() }),
* params: z.object({ id: z.string().uuid() }),
* query: z.object({ include: z.string().optional() }),
* body: z.object({ name: z.string() }),
* response: z.object({ id: z.string(), name: z.string() }),
* },
* handler: (event) => {
* return { success: true };
* // event.context.params, await event.req.json(), and return value are all typed!
* const { id } = event.context.params;
* const body = await event.req.json();
* return { id, name: body.name };
* }
* });
*
* app.register(userRoute);
* app.use(userRoute);
* ```
* `@example`
*
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/route.ts` around lines 133 - 155, The example's handler is using
await without being async and the route registration uses app.use instead of
app.register; update the example so the handler function passed to defineRoute
is declared async (e.g., async (event) => { ... }) so await is valid, and
replace the app.use(userRoute) call with app.register(userRoute) to match the
documented registration API; ensure the symbol names defineRoute, handler arrow
function, and userRoute are adjusted accordingly in the snippet.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
src/utils/route.ts (2)

88-96: Consider consolidating ValidatedRouteEvent with ValidatedH3Event.

This type is structurally identical to ValidatedH3Event in src/handler.ts (lines 72-79). Both define the same pattern:

Omit<H3Event<RequestT>, "context"> & { context: Omit<H3Event["context"], "params"> & { params?: ParamsT } }

Consider exporting one shared type to reduce maintenance burden.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/route.ts` around lines 88 - 96, ValidatedRouteEvent duplicates the
same shape as ValidatedH3Event; consolidate by extracting a single exported
shared type and reuse it in both places: create or export the canonical type
(e.g., ValidatedH3Event) and replace ValidatedRouteEvent usage with that shared
type, removing the duplicate definition; ensure the exported name matches
existing imports/usages and update any imports/usages referencing
ValidatedRouteEvent to the shared type to avoid type drift.

9-11: Consider extracting StringHeaders to avoid duplication.

This type is identical to StringHeaders defined in src/handler.ts (line 62-64). Consider extracting it to a shared location (e.g., src/types/utils.ts) and importing from both files.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/utils/route.ts` around lines 9 - 11, Extract the duplicate type alias
StringHeaders into a shared module and import it where needed: create a new
exported type StringHeaders in a common types file, replace the inline
definition in route.ts (currently declaring type StringHeaders<T>) with an
import of that shared type, and update handler.ts to import the same shared type
instead of redefining it; keep the generic signature and name unchanged so
existing usages compile.
src/handler.ts (1)

145-152: Type cast note: onError callback may receive "response" source.

The cast at line 150 (as OnValidateError<"response">) is safe at runtime, but users providing an onError callback should be aware it will receive _source: "response" for response validation failures in addition to request-side sources like "params", "query", "headers", and "body".

Consider documenting this behavior in the OnValidateError type or the validate.onError property JSDoc.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/handler.ts` around lines 145 - 152, The cast using
OnValidateError<"response"> should be documented or widened so callers know the
onError callback can receive _source: "response" for response-side validation
failures; update the OnValidateError type signature or add JSDoc on the
validate.onError property to explicitly include "response" as a possible source
(or use a union that includes "response"), and mention this behavior near the
validateResponse call and in the OnValidateError JSDoc so users implementing
onError for functions like validateResponse or def.validate know the callback
may be invoked with _source === "response".
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/handler.ts`:
- Around line 145-152: The cast using OnValidateError<"response"> should be
documented or widened so callers know the onError callback can receive _source:
"response" for response-side validation failures; update the OnValidateError
type signature or add JSDoc on the validate.onError property to explicitly
include "response" as a possible source (or use a union that includes
"response"), and mention this behavior near the validateResponse call and in the
OnValidateError JSDoc so users implementing onError for functions like
validateResponse or def.validate know the callback may be invoked with _source
=== "response".

In `@src/utils/route.ts`:
- Around line 88-96: ValidatedRouteEvent duplicates the same shape as
ValidatedH3Event; consolidate by extracting a single exported shared type and
reuse it in both places: create or export the canonical type (e.g.,
ValidatedH3Event) and replace ValidatedRouteEvent usage with that shared type,
removing the duplicate definition; ensure the exported name matches existing
imports/usages and update any imports/usages referencing ValidatedRouteEvent to
the shared type to avoid type drift.
- Around line 9-11: Extract the duplicate type alias StringHeaders into a shared
module and import it where needed: create a new exported type StringHeaders in a
common types file, replace the inline definition in route.ts (currently
declaring type StringHeaders<T>) with an import of that shared type, and update
handler.ts to import the same shared type instead of redefining it; keep the
generic signature and name unchanged so existing usages compile.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 7daa7ad9-981b-4fba-a360-ca8c884f2c9a

📥 Commits

Reviewing files that changed from the base of the PR and between db67cf7 and 01c9843.

📒 Files selected for processing (6)
  • src/handler.ts
  • src/utils/internal/validate.ts
  • src/utils/route.ts
  • test/handler.test.ts
  • test/route.test.ts
  • test/unit/types.test-d.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • test/handler.test.ts

@sandros94

Copy link
Copy Markdown
Member

I just realized that thanks to StandardJSONSchema we could even create proper openapi based on validation input and output much like hono and elysia do, directly into defineRoute logic

@productdevbook

productdevbook commented May 20, 2026

Copy link
Copy Markdown
Member Author

@sandros94 Yes, that was the whole point of it. and CLI support.

@sandros94

sandros94 commented May 22, 2026

Copy link
Copy Markdown
Member

I cannot stop thinking about this PR, I'm sorry...

Another think I realized is that by taking advantage of the StandardSchema part, not only we have the input and output validation as always, but could theoretically also expose the typing, which then a client such as fetchdts or ofetch could directly use.

With a single combination of StandardSchema and StandardJSONSchema this would cover both validation, types and openapi, wihtout re-inventing the wheel nor overcomplicating it for new comers or other frameworks

Sorry for the ping, but when you have time I would also like your opinions on this @pi0 and @danielroe

The only open question that remains to me would be how to handle multiple methods, as semantically speaking I'm now realizing that a defineRoute is about routes in general. It can have multiple methods in its handler, as well as different validations (and therefor types and openapi) for each

@sandros94

Copy link
Copy Markdown
Member

So, ended up drafting an experiment which is about the DX API I'm satisfied with and now I've published for easier feedbacks and testing of that idea.

Essentially I wanted the concept of a set of utils for defining routes much similar to what @productdevbook initially implemented, but both adding multi-method support as well as data validation with full type support, without being limited to async or non async schemas.

Turned out that adding support for nitro and typing a fetcher were two smaller additions that I ended up seeking soon after.

You can find the experiment at sandros94/h3-route-tools, if you have time to take a look at it I'll be happy to hear any feedbacks 🙌

The core is composed of:

  • a defineRouteHandler which is a defineEventHandler-compatible that you input an object containing the methods and validations you'd prefer, internally it register everything under the catch-all route, while exposes both the types if any validation has been added for any methods as well as static openapi if the validation was also a StandardJSONSchema compatible one (this last part is subject to the validation library's capability and extensibility).
  • I wanted to rewrite defineRoute following a similar API for multi-method support, but still outputting an H3Plugin as productdevbook did.
  • a small H3Typed class that is basically H3 but with a .route method and various types, nothing fancy, just temporary for personal use and experiment.
  • then a set of type utilities to extract the types for methods in each route, allowing for creating a typed fetch client and nitro's codegen (also added a small cli to print-to-file the types, something I do quite often at work with elysia since they want to keep backend and frontend to separate repos with separate developers).

There are two playgrounds, one for h3 and the other for a mock nitro+vue example

@pi0x

pi0x commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Note: this analysis was performed by an AI agent (Claude Code) on behalf of the maintainer.

This PR overlaps in design space with #1217 (compile-time route param type inference) and #1339 (getPayload/getValidatedPayload utilities), though none of them directly conflict at the file level. #1217 introduces automatic context.params typing derived from the route string, which could replace this PR's local ValidatedH3Event/params-typing approach — it'd be worth rebasing this PR on top of #1217 (once/if that merges) to avoid two divergent approaches to params typing living in the codebase side by side. #1339 is unrelated work (it unifies query/body/params access into one call) and is not a duplicate of this PR.

No action needed right now — just flagging the relationship for whoever reviews this next.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants