middleware icon indicating copy to clipboard operation
middleware copied to clipboard

[@hono/zod-validator] Type inference fails to include the response from zValidator's "hook"

Open jraoult opened this issue 2 months ago • 5 comments

Which middleware has the bug?

@hono/zod-validator

What version of the middleware?

0.7.4

What version of Hono are you using?

4.10.

What runtime/platform is your app running on? (with version if possible)

Node

What steps can reproduce the bug?

The primary issue is that the official zValidator's error hook fails Hono's type inference, causing its custom error response type (e.g., { error: string }) to be excluded from the final Hono app's route signature, even though a custom wrapper around Hono's native validator function does correctly include the error response type.

You can reproduce with the following snippet

const createZodValidation =
  <TSchema extends z.ZodType>(schema: TSchema) =>
  (value: unknown, ctx: Context) => {
    const result = schema.safeParse(value);
    if (!result.success) {
      return ctx.json({ error: "validation" }, 400);
    }

    return result.data;
  };

const RequestJsonSchema = z.object({ mustBePassed: z.string() });
const app = new Hono().post(
  "/foo",
  //this pattern does not participate in the response type inference
  //zValidator("json", z.object({ mustBePassed: z.string() }), (result, ctx) => {
  //  if (!result.success) {
  //    return ctx.json({ error: "validation" }, 400);
  //  }
  //}),
  // this pattern works and *does* participate in the response type inference
  validator("json", createZodValidation(RequestJsonSchema)),
  (ctx) => {
    ctx.req.valid("json") satisfies z.infer<typeof RequestJsonSchema>;

    if (Date.now() % 2 === 0) {
      return ctx.json({ info: "wuut", extraInfo: "bla" }, 400);
    }

    return ctx.json(
      { allGood: true },
      // OK status is not default and omitting it break the exhaustive check on
      // the client side
      200,
    );
  },
);

and you can use the following client code:

const client = hc<typeof app>("");
  const response = await client["foo"].$post({
    json: { mustBePassed: "foo" },
  });

  const responseJson = await response.json();
  if ("allGood" in responseJson) {
    // noop
  } else if ("info" in responseJson) {
    responseJson satisfies { extraInfo: string };
  } else {
    responseJson satisfies { error: string };
  }

What is the expected behavior?

The following should be inferred:

Hono<
  BlankEnv,
  {
    "/foo": {
      $post:
        | {
            input: { json: { mustBePassed: string } }
            output: { error: string }
            outputFormat: "json"
            status: 400
          }
        | {
            input: { json: { mustBePassed: string } }
            output: { info: string; extraInfo: string }
            outputFormat: "json"
            status: 400
          }
        | {
            input: { json: { mustBePassed: string } }
            output: { allGood: true }
            outputFormat: "json"
            status: 200
          }
    }
  },
  "/"
>

What do you see instead?

Hono<
  BlankEnv,
  {
    "/foo": {
      $post:
        | {
            input: { json: { mustBePassed: string } }
            output: { info: string; extraInfo: string }
            outputFormat: "json"
            status: 400
          }
        | {
            input: { json: { mustBePassed: string } }
            output: { allGood: true }
            outputFormat: "json"
            status: 200
          }
    }
  },
  "/"
>

Additional information

No response

jraoult avatar Nov 07 '25 12:11 jraoult

@jraoult

I am likely encountering the same issue as well. The cause is that while the return type of validator from hono/validator is MiddlewareHandler<E, P, V, ExtractValidationResponse<VF>>, the return type of zValidator from @hono/zod-validator is MiddlewareHandler<E, P, V>, meaning the fourth type parameter is not being set. To address this issue provisionally, we have created the following wrapper typedZodValidator:

  • I apologize that the comments are in Japanese.
  • Please note that this wrapper only supports zod/v4 (it does not support v3).
import { zValidator } from '@hono/zod-validator';
import type {
  Context,
  Env,
  Input,
  MiddlewareHandler,
  TypedResponse,
  ValidationTargets,
} from 'hono';
import type {
  ZodSafeParseResult,
  infer as zInfer,
  input as zInput,
  output as zOutput,
} from 'zod';
import type { $ZodError, $ZodType as ZodSchema } from 'zod/v4/core';

/**
 * @hono/zod-validator から抜粋した ZodError 型と Hook 型
 */
type ZodError<T extends ZodSchema> = $ZodError<zOutput<T>>;
type Hook<
  T,
  E extends Env,
  P extends string,
  Target extends keyof ValidationTargets = keyof ValidationTargets,
  // biome-ignore lint/complexity/noBannedTypes: オリジナルの定義を踏襲
  O = {},
  // biome-ignore lint/suspicious/noExplicitAny: オリジナルの定義を踏襲
  Schema extends ZodSchema = any,
> = (
  result: (
    | {
        success: true;
        data: T;
      }
    | {
        success: false;
        error: ZodError<Schema>;
        data: T;
      }
  ) & {
    target: Target;
  },
  c: Context<E, P>,
) =>
  | Response
  | void
  | TypedResponse<O>
  // biome-ignore lint/suspicious/noConfusingVoidType: オリジナルの定義を踏襲
  | Promise<Response | void | TypedResponse<O>>;

type HasUndefined<T> = undefined extends T ? true : false;

/**
 * hono/validator から抜粋した ExtractValidationResponse 型
 */
type ExtractValidationResponse<VF> = VF extends (
  // biome-ignore lint/suspicious/noExplicitAny: 正確な型定義は困難なため許容
  result: any,
  // biome-ignore lint/suspicious/noExplicitAny: 正確な型定義は困難なため許容
  c: any,
) => infer R
  ? R extends Promise<infer PR>
    ? PR extends TypedResponse<infer T, infer S, infer F>
      ? TypedResponse<T, S, F>
      : PR extends Response
        ? PR
        : PR extends undefined
          ? never
          : never
    : R extends TypedResponse<infer T, infer S, infer F>
      ? TypedResponse<T, S, F>
      : R extends Response
        ? R
        : R extends undefined
          ? never
          : never
  : never;

/**
 * zValidator のラッパー
 *
 * [email protected] には戻り値の型が API レスポンスの型として反映されない課題へ対応するためのラッパー。
 * validator と同様に MiddlewareHandler の第4型引数を設定することで課題へ対応している。
 * zod/v4 のみに対応している。
 */
export const typedZodValidator = <
  T extends ZodSchema,
  Target extends keyof ValidationTargets,
  E extends Env,
  P extends string,
  In = zInput<T>,
  Out = zOutput<T>,
  I extends Input = {
    in: HasUndefined<In> extends true
      ? {
          [K in Target]?: In extends ValidationTargets[K]
            ? In
            : {
                [K2 in keyof In]?: In[K2] extends ValidationTargets[K][K2]
                  ? In[K2]
                  : ValidationTargets[K][K2];
              };
        }
      : {
          [K in Target]: In extends ValidationTargets[K]
            ? In
            : {
                [K2 in keyof In]: In[K2] extends ValidationTargets[K][K2]
                  ? In[K2]
                  : ValidationTargets[K][K2];
              };
        };
    out: { [K in Target]: Out };
  },
  V extends I = I,
  InferredValue = zInfer<T>,
  // biome-ignore lint/complexity/noBannedTypes: デフォルトの初期値として {} が利用されているため許容
  HookFn extends Hook<InferredValue, E, P, Target, {}, T> = Hook<
    InferredValue,
    E,
    P,
    Target,
    // biome-ignore lint/complexity/noBannedTypes: デフォルトの初期値として {} が利用されているため許容
    {},
    T
  >,
>(
  target: Target,
  schema: T,
  hook?: HookFn,
  options?: {
    validationFunction: (
      schema: T,
      value: ValidationTargets[Target],
    ) => ZodSafeParseResult<T> | Promise<ZodSafeParseResult<T>>;
  },
): MiddlewareHandler<E, P, V, ExtractValidationResponse<HookFn>> =>
  // biome-ignore lint/suspicious/noExplicitAny: zValidator では zod/v3 と v4 の両方に対応しているため any として扱う
  zValidator(target, schema, hook as any, options as any) as any;

shoji9x9 avatar Nov 13 '25 05:11 shoji9x9

@shoji9x9 amazing findings. Makes total sense. Thank you for investigating. Since you have pretty much done the work, why not open a PR for this? You're probably the best person right now to do this.

jraoult avatar Nov 13 '25 17:11 jraoult

@shoji9x9

We are waiting for your PR! If you ended up, you can't, feel free to leave it to me. But you're the best person.

yusukebe avatar Nov 13 '25 23:11 yusukebe

@jraoult

I'm glad I could be of help.

I've never been involved in OSS development before and don't fully understand how to create PRs, but I'll give it a try next time.

shoji9x9 avatar Nov 14 '25 05:11 shoji9x9

@yusukebe

I've never created an OSS PR before, but I'll give it a try this time.

shoji9x9 avatar Nov 14 '25 05:11 shoji9x9