React Hook Form 15 ๐ Schema Validation with Zod โ zodResolver and type inference
The zodResolver is the bridge between a Zod schema and the useForm hook. The resolver receives the complete form values, validates them against the schema, and returns the normalized values and the errors in the shape that React Hook Form expects . The schema is the single source of truth for the validation rules, and the z.infer utility is the mechanism that derives the TypeScript type from the schema. The two together are the reason a project can write the validation once and have the runtime check and the compile-time type agree.
The type inference is not a single type. A Zod schema is a pipe with the input type at one end and the output type at the other. The z.infer gives the output type, which is the type after the coercion, the transform, and the default are applied. The z.input gives the input type, which is the type before the processing . For a simple schema with no transforms, the two are the same, and the z.infer alone is sufficient. For a schema that coerces the string to the number, the input is the string and the output is the number, and the distinction is the mechanism that keeps the form and the submit handler honest.
Key point: The zodResolver connects the Zod schema to the useForm. The z.infer<typeof schema> gives the output type, and the z.input<typeof schema> gives the input type. For a schema with coercion, transform, or default, the useForm uses the three generics useForm<z.input<typeof schema>, unknown, z.output<typeof schema>> to type the fields as input and the submit result as output. Without the three generics, the casts hide the mismatch.
Why the zodResolver and the type inference exist
The validation duplication problem. A form needs the runtime validation and the compile-time type. The runtime validation checks the user input, and the compile-time type checks the developer’s code. Without the resolver, the two would be written separately: a validation function and an interface. The two would drift, and the drift is the bug. The zodResolver is the mechanism that derives the runtime validation from the schema, and the z.infer is the mechanism that derives the type from the same schema . The single source is the reason the two cannot disagree.
The input/output split problem. A schema that coerces the string to the number has two types: the raw string that the input holds and the parsed number that the submit handler receives. The z.infer gives the output type, and the z.input gives the input type . The useForm holds the raw values, and the handleSubmit hands the parsed values to the handler. The three generics are the mechanism that types both ends, and the typing is the reason the defaultValues and the submit handler have the correct types.
The resolver inference problem. The zodResolver infers the form values from the schema automatically, and the useForm without the generic can still be type-safe . The explicit generic is the mechanism that documents the type and makes it available for the other uses, such as the props and the function signatures. The inference is the convenience, and the explicit generic is the clarity.
The error-shape problem. The Zod error has the issues array, and each issue has the path and the message. The zodResolver maps the issues to the formState.errors shape, and the errors.field.message is the mechanism that the UI reads . The mapping is the reason the form’s error display works with the schema’s errors.
The Zod version problem. The @hookform/resolvers package has the version compatibility with the Zod v4. The known issue is the TypeScript compatibility, and the workaround is to use the import { z } from 'zod/v3' or to wait for the resolver update . The version pairing is the mechanism that keeps the resolver and the schema compatible.
a. The basic setup and the inferred type
The setup is the three packages: the React Hook Form, the resolvers, and the Zod. The schema is the z.object, and the resolver is the zodResolver(schema).
// The basic zodResolver setup
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
// The schema is the single source of truth.
const loginSchema = z.object({
email: z.string().email("The email is invalid."),
password: z.string().min(8, "The password must be at least 8 characters."),
});
// The inferred type is the form values type.
type LoginFormValues = z.infer<typeof loginSchema>;
function LoginForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginFormValues>({
// The resolver connects the schema to the form.
resolver: zodResolver(loginSchema),
// The defaultValues must match the inferred type.
defaultValues: { email: "", password: "" },
});
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input {...register("email")} />
{errors.email && <p role="alert">{errors.email.message}</p>}
<input {...register("password")} type="password" />
{errors.password && <p role="alert">{errors.password.message}</p>}
<button type="submit">Log in</button>
</form>
);
}
The z.infer<typeof loginSchema> is the mechanism that derives the LoginFormValues from the schema. The type is the output type, and it matches the parsed data that the handleSubmit receives . The defaultValues must match the type, and the mismatch is the compile error.
The errors.email.message is the mechanism that reads the Zod error message. The zodResolver maps the Zod issues to the formState.errors, and the message is the string that the schema’s rule produced . The error display is the same as the built-in rules, and the resolver is the mechanism that makes the schema errors look like the form errors.
b. The input/output split and the three generics
The input/output split is the mechanism that handles the coercion, the transform, and the default. A schema that coerces the string to the number has the input type of the string and the output type of the number.
// The input/output split
const donationSchema = z.object({
// The coercion: the input is the string, the output is the number.
amount: z.coerce.number().positive(),
// The default: the input is optional, the output is the enum.
tier: z.enum(["bronze", "silver", "gold"]).default("bronze"),
});
// The input type is the raw form values.
type DonationInput = z.input<typeof donationSchema>;
// { amount?: unknown; tier?: "bronze" | "silver" | "gold" | undefined }
// The output type is the parsed values.
type DonationOutput = z.output<typeof donationSchema>;
// { amount: number; tier: "bronze" | "silver" | "gold" }
The z.input is the type before the processing, and the z.output is the type after the processing . The useForm holds the input values, and the handleSubmit receives the output values. The three generics are the mechanism that types both.
// The three generics
function DonationForm() {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<
z.input<typeof donationSchema>, // The fields and the defaults
unknown, // The context (unused)
z.output<typeof donationSchema> // The submit result
>({
resolver: zodResolver(donationSchema),
defaultValues: {
// The default is the input type.
// The amount is the string, not the number.
amount: "",
tier: "bronze",
},
});
const onSubmit = (data: z.output<typeof donationSchema>) => {
// The data.amount is the number.
// The data.tier is the enum.
console.log(data);
};
return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* The input is the string, and the schema coerces it. */}
<input {...register("amount")} />
{errors.amount && <p role="alert">{errors.amount.message}</p>}
<button type="submit">Donate</button>
</form>
);
}
The useForm<z.input<typeof schema>, unknown, z.output<typeof schema>> is the mechanism that types the fields as the input and the submit result as the output . The defaultValues is the input type, and the string default is the correct value for the amount field. The onSubmit receives the output type, and the number is the correct type for the amount.
The unknown in the middle is the context type, and it is the placeholder for the useForm‘s context. The unknown is the safe default, and the explicit type is the alternative when the context is used.
Complete Example Session
// ============================================
// PART 1: THE BASIC RESOLVER
// ============================================
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
const schema = z.object({
email: z.string().email("Invalid email."),
password: z.string().min(8, "At least 8 characters."),
});
type FormValues = z.infer<typeof schema>;
function LoginForm() {
const { register, handleSubmit, formState: { errors } } = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { email: "", password: "" },
});
return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<input {...register("email")} />
{errors.email && <p role="alert">{errors.email.message}</p>}
<input {...register("password")} type="password" />
{errors.password && <p role="alert">{errors.password.message}</p>}
</form>
);
}
// ============================================
// PART 2: THE INFERRED TYPE
// ============================================
// The z.infer is the output type.
type FormValues = z.infer<typeof schema>;
// The z.input is the input type.
type FormInput = z.input<typeof schema>;
// ============================================
// PART 3: THE COERCION
// ============================================
const ageSchema = z.object({
age: z.coerce.number().min(18, "Must be 18 or older."),
});
// The input is the string, the output is the number.
// ============================================
// PART 4: THE THREE GENERICS
// ============================================
const form = useForm<
z.input<typeof ageSchema>,
unknown,
z.output<typeof ageSchema>
>({
resolver: zodResolver(ageSchema),
defaultValues: { age: "" }, // The string is the input type
});
// ============================================
// PART 5: THE SUBMIT HANDLER
// ============================================
const onSubmit = (data: z.output<typeof ageSchema>) => {
// The data.age is the number.
console.log(data.age);
};
// ============================================
// PART 6: THE TRANSFORM
// ============================================
const dateSchema = z.object({
date: z.string().transform((s) => new Date(s)),
});
// The input is the string, the output is the Date.
type DateInput = z.input<typeof dateSchema>; // { date: string }
type DateOutput = z.output<typeof dateSchema>; // { date: Date }
// ============================================
// PART 7: THE REFINEMENT
// ============================================
const passwordSchema = z.object({
password: z.string().min(8),
confirm: z.string(),
}).refine((data) => data.password === data.confirm, {
message: "The passwords do not match.",
path: ["confirm"], // The error path is the confirm field
});
// ============================================
// PART 8: THE SERVER ERROR
// ============================================
const onSubmit = async (data: z.infer<typeof schema>) => {
const response = await fetch("/api/login", {
method: "POST",
body: JSON.stringify(data),
});
if (!response.ok) {
const body = await response.json();
setError("email", { type: "server", message: body.message });
}
};
// ============================================
// PART 9: THE ZOD VERSION
// ============================================
// The Zod v4 TypeScript compatibility issue with the resolver.
// The workaround is the import from "zod/v3".
import { z } from "zod/v3";
// ============================================
// PART 10: THE CHECKLIST
// ============================================
// [ ] The zodResolver is the resolver.
// [ ] The z.infer is the form values type.
// [ ] The z.input and the z.output are the three generics when transforming.
// [ ] The defaultValues matches the input type.
// [ ] The refinement has the path for the field error.
// [ ] The server errors are set with the setError.
The ten parts covered the basic resolver, the inferred type, the coercion, the three generics, the submit handler, the transform, the refinement, the server error, the Zod version, and the checklist. The through-line is that the schema is the source, and the inference is the derivation.
Quick Reference
The packages
| Package | Purpose |
|---|---|
react-hook-form | The form hook |
@hookform/resolvers | The resolver package |
zod | The schema library |
The inference utilities
| Utility | Type |
|---|---|
z.infer<typeof schema> | The output type |
z.input<typeof schema> | The input type |
z.output<typeof schema> | The output type (same as the infer) |
The three generics
| Generic | Purpose |
|---|---|
z.input<typeof schema> | The fields and the defaults |
unknown | The context |
z.output<typeof schema> | The submit result |
The resolver options
| Option | Purpose |
|---|---|
mode: "async" | The async validation (default) |
mode: "sync" | The sync validation |
raw: true | The unparsed values |
Best Practices
โ Do This:
// Infer the type from the schema
type FormValues = z.infer<typeof schema>; // โ
// Use the three generics for the transforming schema
useForm<z.input<typeof schema>, unknown, z.output<typeof schema>>(); // โ
// Match the defaultValues to the input type
useForm({ defaultValues: { age: "" } }); // โ
โ Don’t Do This:
// Hand-write the type that mirrors the schema
interface FormValues { email: string; } // the drift risk // โ
// Cast the output to the input type
onSubmit={(data) => handler(data as FormInput)} // the hidden bug // โ
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| The default value is the wrong type | The output type is used for the input | Use the z.input type |
| The submit handler receives the wrong type | The one generic is used | Use the three generics |
| The refinement error is not on the field | The path is missing | Add the path to the refinement |
| The resolver fails with the Zod v4 | The TypeScript compatibility | Use the zod/v3 import |
Real-World Examples
1. The basic schema
const schema = z.object({ email: z.string().email() });
2. The inferred type
type FormValues = z.infer<typeof schema>;
3. The resolver
useForm({ resolver: zodResolver(schema) });
4. The coercion
z.coerce.number()
5. The three generics
useForm<z.input<typeof schema>, unknown, z.output<typeof schema>>()
6. The transform
z.string().transform((s) => new Date(s))
7. The refinement
.refine((data) => data.a === data.b, { path: ["b"] })
8. The server error
setError("email", { type: "server", message: body.message })
9. The Zod v3 import
import { z } from "zod/v3"
10. The submit handler
const onSubmit = (data: z.infer<typeof schema>) => {}
Visual
The Schema and the Inference
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ THE SCHEMA IS THE SOURCE โ
โ โ
โ The Zod schema โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ z.object({ ... }) โ The validation rules โ
โ โโโโโโโโโโโโฌโโโโโโโโโโโ โ
โ โ โ
โ โโโโโโโโดโโโโโโโ โ
โ โผ โผ โ
โ โโโโโโโโโโ โโโโโโโโโโ โ
โ โz.infer โ โzod โ โ
โ โ โ โResolverโ โ
โ โThe typeโ โThe โ โ
โ โ โ โruntime โ โ
โ โโโโโโโโโโ โโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The Input/Output Split
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ THE INPUT/OUTPUT SPLIT โ
โ โ
โ The schema: z.coerce.number() โ
โ โ โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ The input: string โ The raw field value โ
โ โโโโโโโโโโโโฌโโโโโโโโโโโ โ
โ โ โ
โ โผ โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โ โ The output: number โ The parsed value โ
โ โโโโโโโโโโโโโโโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
The Three Generics
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ THE THREE GENERICS โ
โ โ
โ useForm<Input, Context, Output> โ
โ โ โ โ โ
โ โผ โผ โผ โ
โ โโโโโโโโโโ โโโโโโโโโโ โโโโโโโโโโ โ
โ โFields โ โThe โ โSubmit โ โ
โ โDefaultsโ โcontext โ โHandler โ โ
โ โโโโโโโโโโ โโโโโโโโโโ โโโโโโโโโโ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Summary
| Item | Value |
|---|---|
| The resolver | zodResolver(schema) |
| The inference | z.infer<typeof schema> |
| The input type | z.input<typeof schema> |
| The output type | z.output<typeof schema> |
| The three generics | useForm<Input, unknown, Output> |
| The refinement path | The path in the .refine() |
| The Zod version | The zod/v3 import for the compatibility |
Key takeaways:
- The
zodResolverconnects the Zod schema to theuseForm. The resolver validates the form values against the schema and maps the Zod issues to theformState.errors. The schema is the single source of truth for the validation, and the resolver is the runtime mechanism . - The
z.inferderives the type from the schema. The inferred type is the output type, and it matches the parsed data that the submit handler receives. The hand-written type that mirrors the schema is the drift risk, and the inference is the mechanism that removes it . - The input/output split matters when the schema transforms. The
z.inputis the raw field type, and thez.outputis the parsed type. The three genericsuseForm<Input, unknown, Output>type the fields as the input and the submit result as the output . - The
defaultValuesmust match the input type. The default value is the raw field value, and the string is the correct default for the numeric field that the schema coerces. The mismatch is the compile error, and the error is the mechanism that catches the wrong default. - The Zod v4 has the TypeScript compatibility issue with the resolver. The workaround is to use the
zod/v3import, or to wait for the resolver update. The version pairing is the mechanism that keeps the resolver and the schema compatible .
Remember: The React Hook Form Zod validation is the zodResolver and the z.infer. The resolver connects the schema to the form, and the inference derives the type. The input/output split is the mechanism for the transforming schemas, and the three generics type the fields and the submit result. The schema is the source, and the type and the validation follow.
Stop using slow, ad-bloated tool sites! ๐คฎ
๐ Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
โ
Finance (Mortgage, Interest, Inflation)
โ
Tech (Base64, JSON, Dev Suite, IP)
โ
Health (BMI, BMR, TDEE)
โ
Productivity (Timer, Workspace, QR)
โก๏ธ Fast & Private
๐ No data leaves your device
๐ 100% Free
๐ Use it now: https://tools.kandz.me
๐ Bookmark itโyouโll need it later!