@zod-utils/react-hook-form
v2.0.2
Published
React Hook Form integration and utilities for Zod schemas
Downloads
1,262
Maintainers
Readme
@zod-utils/react-hook-form
React Hook Form integration and utilities for Zod schemas.
💡 Why Use This?
The whole point: Automatically transforms your Zod schema types so form inputs accept undefined (and null for objects only) during editing, while the validated output remains exactly as your Zod schema defines.
No more type wrestling with React Hook Form - just pass your schema and it works.
import { useZodForm } from "@zod-utils/react-hook-form";
import { z } from "zod";
// Your schema with primitives, arrays, and objects - NOT optional
const schema = z.object({
username: z.string().min(3),
age: z.number().min(18),
tags: z.array(z.string()),
profile: z.object({ bio: z.string() }),
});
const form = useZodForm({ schema });
// ✅ Works! Primitives and arrays accept undefined during editing
form.setValue("username", undefined);
form.setValue("age", undefined);
form.setValue("tags", undefined);
// ✅ Works! Objects accept both null and undefined
form.setValue("profile", null);
form.setValue("profile", undefined);
// ✅ Validated output type is exactly z.infer<typeof schema>
const onSubmit = form.handleSubmit((data) => {
// Type: { username: string; age: number; tags: string[]; profile: { bio: string } }
// NOT { username: string | null | undefined; ... }
console.log(data.username); // Type: string
console.log(data.age); // Type: number
console.log(data.tags); // Type: string[]
console.log(data.profile); // Type: { bio: string }
});Installation
npm install @zod-utils/react-hook-form zod react react-hook-form @hookform/resolversRelated Packages
- @zod-utils/core - Pure TypeScript utilities for Zod schema manipulation (no React dependencies). All utilities are re-exported from this package for convenience.
Features
- 🎣 useZodForm - Automatic type transformation for form inputs (nullable/undefined) while preserving Zod schema validation
- 📋 FormSchemaProvider - React Context for providing schema to form components
- ✅ useIsRequiredField - Hook to check if a field requires valid input
- 🔄 Discriminated Union Support - Full type-safe support for discriminated unions
- 📦 All core utilities - Re-exports everything from
@zod-utils/core - ⚛️ React-optimized - Built specifically for React applications
Quick Start
import { useZodForm, getSchemaDefaults } from "@zod-utils/react-hook-form";
import { z } from "zod";
const schema = z.object({
name: z.string().default("John Doe"),
email: z.string().email(),
age: z.number().min(18),
});
function MyForm() {
const form = useZodForm({
schema,
defaultValues: getSchemaDefaults(schema),
});
const onSubmit = form.handleSubmit((data) => {
console.log(data);
});
return (
<form onSubmit={onSubmit}>
<input {...form.register("name")} />
<input {...form.register("email")} type="email" />
<input {...form.register("age")} type="number" />
<button type="submit">Submit</button>
</form>
);
}API Reference
useZodForm(config)
Type-safe wrapper around React Hook Form's useForm with automatic Zod schema integration.
import { useZodForm } from "@zod-utils/react-hook-form";
import { z } from "zod";
const schema = z.object({
username: z.string().min(3),
password: z.string().min(8),
});
const form = useZodForm({
schema, // Zod schema (required)
defaultValues: {
/* ... */
}, // Optional default values
zodResolverOptions: {
/* ... */
}, // Optional zodResolver options
// ... all other useForm options
});What it does:
- Input transformation (by default):
- Primitive fields (string, number, boolean) accept
undefinedonly - Array fields accept
undefinedonly - Object fields accept both
nullandundefined - You can override this by specifying a custom input type (see examples below)
- Primitive fields (string, number, boolean) accept
- Output validation: Validated data matches your Zod schema exactly
- Type inference: No manual type annotations needed - everything is inferred from the schema
- Zod integration: Automatically sets up
zodResolverfor validation - Transform support: Works with schemas that use
.transform()- uses input types for form fields
Using Without Default Values
The defaultValues parameter is optional. All form fields are automatically treated as optional during editing:
const schema = z.object({
name: z.string().min(1),
email: z.string().email(),
age: z.number(),
});
// ✅ No defaultValues needed!
const form = useZodForm({ schema });
// Fields can be set individually as the user types
form.setValue("name", "John");
form.setValue("email", "[email protected]");
form.setValue("age", 25);
// Validation still enforces the schema on submit
const onSubmit = form.handleSubmit((data) => {
// Type: { name: string; email: string; age: number }
console.log(data);
});This works because useZodForm uses the Simplify utility to ensure proper type inference, making all fields optional during editing while preserving exact types after validation.
Default Values Type Safety
The defaultValues parameter enforces shallow partial typing for type safety. This means:
- ✅ Top-level fields can be omitted or set to
null/undefined - ✅ Nested objects can be omitted entirely
- ❌ Nested objects cannot be partially filled - they must be complete if provided
const schema = z.object({
user: z.object({
name: z.string(),
email: z.string().email(),
age: z.number(),
}),
settings: z.object({
theme: z.enum(["light", "dark"]),
notifications: z.boolean(),
}),
});
// ✅ Correct: Omit nested objects
const form = useZodForm({
schema,
defaultValues: {
// settings omitted - OK
},
});
// ✅ Correct: Provide complete nested objects
const form = useZodForm({
schema,
defaultValues: {
user: { name: "John", email: "[email protected]", age: 30 }, // Complete
settings: { theme: "dark", notifications: true }, // Complete
},
});
// ❌ TypeScript Error: Partial nested objects not allowed
const form = useZodForm({
schema,
defaultValues: {
user: { name: "John" }, // ❌ Missing email and age
},
});Why this restriction? This prevents type errors where partial nested objects might be missing required properties. If you need to provide partial nested defaults, use getSchemaDefaults() which handles this correctly:
const form = useZodForm({
schema,
defaultValues: getSchemaDefaults(schema), // ✅ Type-safe partial defaults
});Note: This restriction only applies to the defaultValues parameter. During form editing, all fields still accept null/undefined as expected:
// ✅ Works! Form inputs still accept null/undefined
form.setValue("user", null);
form.setValue("settings", null);
form.reset({ user: null, settings: undefined });Custom Input Types
You can override the default input type transformation if needed:
import {
useZodForm,
PartialWithAllNullables,
} from "@zod-utils/react-hook-form";
import { z } from "zod";
const schema = z.object({
username: z.string().min(3),
email: z.string().email(),
age: z.number(),
});
// Option 1: Use PartialWithAllNullables to make ALL fields accept null
const form = useZodForm<
z.infer<typeof schema>,
PartialWithAllNullables<z.infer<typeof schema>>
>({
schema,
defaultValues: { username: null, email: null, age: null },
});
// Option 2: Specify exact input types per field
const form2 = useZodForm<
z.infer<typeof schema>,
{
username?: string | null; // Can be set to null
email?: string; // Can only be undefined
age?: number | null; // Can be set to null
}
>({
schema,
defaultValues: { username: null, email: undefined, age: null },
});Form Schema Context
The Form Schema Context system allows you to provide Zod schema context to deeply nested form components without prop drilling.
FormSchemaProvider
Provides schema context to all child components. Use this to wrap your form.
import { FormSchemaProvider } from "@zod-utils/react-hook-form";
import { z } from "zod";
const schema = z.object({
username: z.string().min(3).max(20),
email: z.string().email(),
});
function MyForm() {
return (
<FormSchemaProvider schema={schema}>
<form>
<UsernameField />
<EmailField />
</form>
</FormSchemaProvider>
);
}With Discriminated Union
For discriminated unions, pass the discriminator to enable type-safe field access:
const schema = z.discriminatedUnion("mode", [
z.object({ mode: z.literal("create"), name: z.string().min(1) }),
z.object({ mode: z.literal("edit"), id: z.number() }),
]);
function CreateModeForm() {
return (
<FormSchemaProvider
schema={schema}
discriminator={{ key: "mode", value: "create" }}
>
<NameField /> {/* Only fields from 'create' variant are available */}
</FormSchemaProvider>
);
}useFormSchema()
Access the schema context from child components:
import { useFormSchema } from "@zod-utils/react-hook-form";
function FieldComponent() {
const context = useFormSchema();
if (!context) return null;
const { schema, discriminator } = context;
// Use schema for field-level logic
}useIsRequiredField({ schema?, name?, discriminator? })
Hook to check if a field requires valid input (shows validation errors on submit).
Returns false if schema or name is not provided, making it safe to use in conditional contexts.
import { useIsRequiredField } from "@zod-utils/react-hook-form";
function FormLabel({ name, schema }: { name: string; schema: z.ZodType }) {
const isRequired = useIsRequiredField({ schema, name });
return (
<label>
{name}
{isRequired && <span className="text-red-500">*</span>}
</label>
);
}isRequiredField({ schema, name, discriminator? })
Standalone function to check if a field requires valid input:
import { isRequiredField } from "@zod-utils/react-hook-form";
import { z } from "zod";
const schema = z.object({
username: z.string().min(1), // Required - min(1) rejects empty
email: z.string(), // Not required - accepts empty string
age: z.number(), // Required - numbers reject empty input
bio: z.string().optional(), // Not required - optional
});
isRequiredField({ schema, name: "username" }); // true
isRequiredField({ schema, name: "email" }); // false
isRequiredField({ schema, name: "age" }); // true
isRequiredField({ schema, name: "bio" }); // falseuseExtractFieldFromSchema({ schema, name, discriminator? })
Hook to extract a field's Zod schema from a parent schema. Memoized for performance.
import { useExtractFieldFromSchema } from "@zod-utils/react-hook-form";
function FieldInfo({ schema, name }: { schema: z.ZodType; name: string }) {
const fieldSchema = useExtractFieldFromSchema({ schema, name });
if (!fieldSchema) return null;
// Use fieldSchema for custom validation or field info
return <span>{fieldSchema._zod.def.typeName}</span>;
}useFieldChecks({ schema, name, discriminator? })
Hook to get validation checks from a field's Zod schema. Useful for displaying validation hints like max length or min/max values.
import { useFieldChecks } from "@zod-utils/react-hook-form";
function FieldHint({ schema, name }: { schema: z.ZodType; name: string }) {
const checks = useFieldChecks({ schema, name });
const maxLength = checks.find((c) => c.check === "max_length");
if (maxLength) {
return <span>Max {maxLength.maximum} characters</span>;
}
return null;
}Supported check types: min_length, max_length, greater_than, less_than, string_format, and more.
Core Utilities (Re-exported)
All utilities from @zod-utils/core are re-exported for convenience:
import {
// Schema utilities (from @zod-utils/core)
getSchemaDefaults,
requiresValidInput,
getPrimitiveType,
removeDefault,
extractDefaultValue,
extendWithMeta,
extractFieldFromSchema,
getFieldChecks,
type Simplify,
type ZodUnionCheck,
// Form schema context & hooks
FormSchemaContext,
FormSchemaProvider,
useFormSchema,
useIsRequiredField,
isRequiredField,
useExtractFieldFromSchema,
useFieldChecks,
// Type utilities
type PartialWithNullableObjects,
type PartialWithAllNullables,
type Discriminator,
type DiscriminatorKey,
type DiscriminatorValue,
type InferredFieldValues,
type ValidFieldPaths,
type ValidFieldPathsOfType,
} from "@zod-utils/react-hook-form";See @zod-utils/core documentation for details on schema utilities.
Type Utilities
PartialWithNullableObjects<T>
Transforms properties based on their type. Primitive and array fields become optional-only (not nullable), while object fields become optional and nullable.
Transformation rules:
- Primitives (string, number, boolean): optional →
type | undefined - Arrays: optional →
type[] | undefined - Objects: optional and nullable →
type | null | undefined
import type { PartialWithNullableObjects } from "@zod-utils/react-hook-form";
type User = {
name: string;
age: number;
tags: string[];
profile: { bio: string };
};
type FormInput = PartialWithNullableObjects<User>;
// {
// name?: string; // Primitive: optional, not nullable
// age?: number; // Primitive: optional, not nullable
// tags?: string[]; // Array: optional, not nullable
// profile?: { bio: string } | null; // Object: optional AND nullable
// }This type is used internally by useZodForm to allow form fields to accept undefined (and null for objects only) during editing while maintaining proper validation types.
PartialWithAllNullables<T>
Makes all fields optional and nullable, regardless of type.
Transformation rules:
- All fields: optional and nullable →
type | null | undefined
import type { PartialWithAllNullables } from "@zod-utils/react-hook-form";
type User = {
name: string;
age: number;
tags: string[];
};
type FormInput = PartialWithAllNullables<User>;
// {
// name?: string | null; // All fields: optional AND nullable
// age?: number | null; // All fields: optional AND nullable
// tags?: string[] | null; // All fields: optional AND nullable
// }Use this when all fields need to accept null, not just objects/arrays.
ValidFieldPathsOfType<TSchema, TValueConstraint, TDiscriminatorKey?, TDiscriminatorValue?, TFieldValues?>
Extracts field paths where the value type matches a constraint. Useful for building type-safe form components that only accept paths of specific types.
import type { ValidFieldPathsOfType } from "@zod-utils/react-hook-form";
import { z } from "zod";
const schema = z.object({
name: z.string(),
age: z.number(),
score: z.number().optional(),
active: z.boolean(),
});
// Only accept number field paths
type NumberPaths = ValidFieldPathsOfType<typeof schema, number>;
// "age" | "score"
// Only accept boolean field paths
type BooleanPaths = ValidFieldPathsOfType<typeof schema, boolean>;
// "active"Use case - Type-safe form field components:
// NumberFormField only accepts paths to number fields
function NumberFormField<
TSchema extends z.ZodType,
TPath extends ValidFieldPathsOfType<TSchema, number>
>({ schema, name }: { schema: TSchema; name: TPath }) {
// name is guaranteed to be a path to a number field
return <input type="number" name={name} />;
}
// ✅ Works - 'age' is a number field
<NumberFormField schema={schema} name="age" />
// ❌ TypeScript error - 'name' is a string field, not number
<NumberFormField schema={schema} name="name" />With discriminated unions:
const formSchema = z.discriminatedUnion("mode", [
z.object({ mode: z.literal("create"), name: z.string(), priority: z.number() }),
z.object({ mode: z.literal("edit"), id: z.number(), rating: z.number() }),
]);
// Number paths for 'edit' variant
type EditNumberPaths = ValidFieldPathsOfType<
typeof formSchema,
number,
"mode",
"edit"
>;
// "id" | "rating"Complete Example
import { useZodForm, getSchemaDefaults } from "@zod-utils/react-hook-form";
import { z } from "zod";
const userSchema = z.object({
profile: z.object({
firstName: z.string().min(1, "First name is required"),
lastName: z.string().min(1, "Last name is required"),
age: z.number().min(18).max(120),
}),
contact: z.object({
email: z.string().email(),
phone: z.string().optional(),
}),
preferences: z.object({
theme: z.enum(["light", "dark"]).default("light"),
notifications: z.boolean().default(true),
}),
});
function UserForm() {
const form = useZodForm({
schema: userSchema,
defaultValues: getSchemaDefaults(userSchema),
});
const onSubmit = form.handleSubmit((data) => {
console.log("Valid data:", data);
});
return (
<form onSubmit={onSubmit}>
{/* Profile */}
<input {...form.register("profile.firstName")} />
{form.formState.errors.profile?.firstName && (
<span>{form.formState.errors.profile.firstName.message}</span>
)}
<input {...form.register("profile.lastName")} />
{form.formState.errors.profile?.lastName && (
<span>{form.formState.errors.profile.lastName.message}</span>
)}
<input
{...form.register("profile.age", { valueAsNumber: true })}
type="number"
/>
{form.formState.errors.profile?.age && (
<span>{form.formState.errors.profile.age.message}</span>
)}
{/* Contact */}
<input {...form.register("contact.email")} type="email" />
{form.formState.errors.contact?.email && (
<span>{form.formState.errors.contact.email.message}</span>
)}
<input {...form.register("contact.phone")} type="tel" />
{/* Preferences - pre-filled with defaults */}
<select {...form.register("preferences.theme")}>
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
<label>
<input
{...form.register("preferences.notifications")}
type="checkbox"
/>
Enable notifications
</label>
<button type="submit">Submit</button>
</form>
);
}TypeScript Support
Fully typed with TypeScript for the best developer experience:
const form = useZodForm({
schema: userSchema,
defaultValues: getSchemaDefaults(userSchema),
});
// ✅ Fully typed
form.register("profile.firstName");
// ❌ TypeScript error
form.register("nonexistent.field");License
MIT
