Skip to content

toTypedSchema initializes nested object fields as {field: undefined} instead of undefined when root schema is ZodObject, but not when it is ZodEffects #5156

Description

@howiezhao

When using useForm with toTypedSchema, a nested object field that is not present in initialValues gets initialized differently depending on whether the root schema is a plain ZodObject or a ZodEffects (produced by .refine() / .superRefine()).

  • Root schema is ZodObject (no .refine()) → field is initialized as { value: undefined, label: undefined }
  • Root schema is ZodEffects (with .refine()) → field is initialized as undefined

This inconsistency is surprising because the shape of the nested field itself has not changed — only the root schema wrapper differs.

Minimal Reproduction

const schema = z.object({
  name: z.string(),
  priceType: z.object({
    value: z.string(),
    label: z.string(),
  }),
});

// Without .refine() — root is ZodObject
const { values } = useForm({
  validationSchema: toTypedSchema(schema),
  initialValues: { name: '' },
});
console.log(values.priceType);
// → { value: undefined, label: undefined }

// With .refine() — root is ZodEffects
const { values: values2 } = useForm({
  validationSchema: toTypedSchema(schema.refine(() => true)),
  initialValues: { name: '' },
});
console.log(values2.priceType);
// → undefined

Observed Cause

The cast function calls getDefaults(zodSchema) when zodSchema.parse() throws. getDefaults checks instanceof ZodObject at the top level:

function getDefaults(schema) {
  if (!(schema instanceof ZodObject)) {
    return undefined; // ZodEffects reaches this and returns early
  }
  return Object.fromEntries(Object.entries(schema.shape).map(([key, value]) => {
    // ...
    if (value instanceof ZodObject) {
      return [key, getDefaults(value)]; // produces { value: undefined, label: undefined }
    }
    return [key, undefined];
  }));
}

When the root is ZodEffects, getDefaults returns undefined immediately, so no synthetic defaults are injected and the nested field stays undefined. When the root is ZodObject, getDefaults recurses into nested ZodObject fields and synthesizes an empty object for them.

Workaround

Explicitly set the field to undefined in initialValues:

const { values } = useForm({
  validationSchema: toTypedSchema(schema),
  initialValues: {
    name: '',
    priceType: undefined,
  },
});

Question

Is it intended that nested object fields without an explicit .default() are synthesized as { field: undefined } rather than left as undefined? And if so, should the behavior be consistent regardless of whether the root schema is ZodObject or ZodEffects?

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions