@zod-utils/react-hook-form
v7.1.0
Published
React Hook Form integration and utilities for Zod schemas
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).
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,
// Form field utilities
flattenFieldSelector,
// Type utilities
type PartialWithNullableObjects,
type PartialWithAllNullables,
type PartialFields,
partialFields,
type DiscriminatorProps,
type DiscriminatorKey,
type DiscriminatorValue,
type SchemaProps,
type SchemaAndDiscriminatorProps,
type NameProps,
type NameAndDiscriminatorProps,
type FieldSelectorProps,
} 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. By default, non-recursive - nested object fields stay strict.
Transformation rules:
- Primitives (string, number, boolean): optional →
type | undefined - Arrays: optional →
type[] | undefined - Built-in objects (Date, RegExp, etc.): optional and nullable →
type | null | undefined - Plain objects: optional and nullable, but nested fields stay strict →
{ strictField: type } | null | undefined - Objects marked with
partialFields(): optional, nullable, and recursively transformed on direct fields
import type { PartialWithNullableObjects } from "@zod-utils/react-hook-form";
type User = {
name: string;
age: number;
tags: string[];
profile: { bio: string; settings: { theme: string } };
};
type FormInput = PartialWithNullableObjects<User>;
// {
// name?: string; // Primitive: optional, not nullable
// age?: number; // Primitive: optional, not nullable
// tags?: string[]; // Array: optional, not nullable
// profile?: { // Object: optional, nullable
// bio: string; // Nested field: STRICT (not optional)
// settings: { theme: string }; // Nested object: STRICT
// } | null;
// }This is ideal for forms where nested objects come from selectors/dropdowns (should be complete when provided).
PartialWithAllNullables<T>
Makes all fields optional and nullable, but by default non-recursive - nested object fields stay strict.
Transformation rules:
- Primitives: optional and nullable →
type | null | undefined - Arrays: optional and nullable →
type[] | null | undefined - Plain objects: optional and nullable, but nested fields stay strict
- Objects marked with
partialFields(): optional, nullable, and recursively transformed on direct fields
import type { PartialWithAllNullables } from "@zod-utils/react-hook-form";
type User = {
name: string;
age: number;
profile: { bio: string };
};
type FormInput = PartialWithAllNullables<User>;
// {
// name?: string | null; // Primitive: optional AND nullable
// age?: number | null; // Primitive: optional AND nullable
// profile?: { bio: string } | null; // Object: nullable, but bio is STRICT
// }partialFields(schema) - Opt-in Recursive Transformation
Use partialFields() to mark specific nested objects that should have their direct fields made partial. This is useful for objects where users fill in fields manually (vs. objects selected from dropdowns).
import { partialFields } from "@zod-utils/react-hook-form";
import { z } from "zod";
const schema = z.object({
price: z.number(),
// User fills in these fields - opt-in to partial
detail: partialFields(
z.object({
hotel: z.string(),
nights: z.number(),
})
),
// Selected from dropdown - stays strict
agent: z.object({
name: z.string(),
fee: z.number(),
}),
});
type FormInput = PartialWithNullableObjects<z.infer<typeof schema>>;
// {
// price?: number;
// detail?: {
// hotel?: string; // Partial - user input
// nights?: number; // Partial - user input
// } | null;
// agent?: {
// name: string; // STRICT - from selector
// fee: number; // STRICT - from selector
// } | null;
// }Note: partialFields() only affects the direct fields of the marked object. Nested objects within it will still stay strict unless they are also wrapped with partialFields().
flattenFieldSelector(params)
Flattens a FieldSelector into an array of primitive values for use in React dependency arrays.
import { flattenFieldSelector, extractFieldFromSchema } from "@zod-utils/react-hook-form";
function useFieldSchema(params) {
return useMemo(() => {
return extractFieldFromSchema(params);
}, flattenFieldSelector(params));
}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
