React Hook Form integration and utilities for Zod schemas
npm install @zod-utils/react-hook-form






React Hook Form integration and utilities for Zod schemas.
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.
``typescript
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
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 }
});
`
`bash`
npm install @zod-utils/react-hook-form zod react react-hook-form @hookform/resolvers
- @zod-utils/core - Pure TypeScript utilities for Zod schema manipulation (no React dependencies). All utilities are re-exported from this package for convenience.
- 🎣 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
`typescript
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 (
API Reference
$3
Type-safe wrapper around React Hook Form's
useForm with automatic Zod schema integration.`typescript
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
undefined only
- Array fields accept undefined only
- Object fields accept both null and undefined
- You can override this by specifying a custom input type (see examples below)
- 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 zodResolver for 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:`typescript
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", "john@example.com");
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`typescript
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: "john@example.com", 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:`typescript
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:`typescript
// ✅ 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:
`typescript
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,
PartialWithAllNullables>
>({
schema,
defaultValues: { username: null, email: null, age: null },
});
// Option 2: Specify exact input types per field
const form2 = useZodForm<
z.infer,
{
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.
$3
Provides schema context to all child components. Use this to wrap your form.
`tsx
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 (
);
}
`#### With Discriminated Union
For discriminated unions, pass the discriminator to enable type-safe field access:
`tsx
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 (
schema={schema}
discriminator={{ key: "mode", value: "create" }}
>
{/ Only fields from 'create' variant are available /}
);
}
`$3
Access the schema context from child components:
`tsx
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
}
`$3
Hook to check if a field requires valid input (shows validation errors on submit).
`tsx
import { useIsRequiredField } from "@zod-utils/react-hook-form";function FormLabel({ name, schema }: { name: string; schema: z.ZodType }) {
const isRequired = useIsRequiredField({ schema, name });
return (
);
}
`$3
Standalone function to check if a field requires valid input:
`tsx
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" }); // false
`$3
Hook to extract a field's Zod schema from a parent schema. Memoized for performance.
`tsx
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 {fieldSchema._zod.def.typeName};
}
`$3
Hook to get validation checks from a field's Zod schema. Useful for displaying validation hints like max length or min/max values.
`tsx
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 Max {maxLength.maximum} characters;
}
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:`typescript
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.
$3
####
PartialWithNullableObjectsTransforms 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`typescript
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;
// {
// 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).
####
PartialWithAllNullablesMakes 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`typescript
import type { PartialWithAllNullables } from "@zod-utils/react-hook-form";type User = {
name: string;
age: number;
profile: { bio: string };
};
type FormInput = PartialWithAllNullables;
// {
// 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 TransformationUse
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).`typescript
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>;
// {
// 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.`tsx
import { flattenFieldSelector, extractFieldFromSchema } from "@zod-utils/react-hook-form";function useFieldSchema(params) {
return useMemo(() => {
return extractFieldFromSchema(params);
}, flattenFieldSelector(params));
}
`---
Complete Example
`typescript
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 (
);
}
`---
TypeScript Support
Fully typed with TypeScript for the best developer experience:
`typescript
const form = useZodForm({
schema: userSchema,
defaultValues: getSchemaDefaults(userSchema),
});// ✅ Fully typed
form.register("profile.firstName");
// ❌ TypeScript error
form.register("nonexistent.field");
``---
MIT