reactjstypescripttypescript-genericsreact-hook-formzod

Recursively map through zod shape


I have react-hook-form and zod/v4 for forms. I define zod validation object, and pass it as resolver to useForm. zod marks all fields as required by default and could mark them as optional. I need to explicitly show whether the field is optional or required (add text 'Optional if so'). But zod scheme does not pass the info to react-hook-form, therefore this info can't be reached from useController.

So, I came up with the idea to make a helper function that takes an already defined zod scheme and returns object that is exactly like the shape of the scheme, but with a boolean value indicating whether the field is required or not.

// input
const schema = z.object({
  name: z.string(),
  email: z.email().optional(),
  address: z.object({
    line1: z.string(),
    line2: z.string().optional(),
  }),
})

// output
{
  name: true,
  email: false
  address: {
    line1: true,
    line2: false,
  },
}

I already came up with this function, but it works only with one level of nesting.

function isOptional(schema: ZodType) {
  return schema.safeParse(undefined).success
}

export function getRequiredFields<T extends Record<string, ZodType>>(
  schema: ZodObject<T>
) {
  const { shape } = schema
  const map = {} as Record<keyof T, boolean>

  for (const key in shape) {
    const field = shape[key]
    map[key] = !isOptional(field)
  }
  return map
}

This is my improved version for any level of nesting:

type RequiredFieldsMap<T> = {
  [Key in keyof T]: boolean | RequiredFieldsMap<T[Key]>
}

export function getRequiredFields<T extends Record<string, ZodType>>(
  schema: ZodObject<T>
) {
  const { shape } = schema
  const map = {} as RequiredFieldsMap<T>

  for (const key in shape) {
    const field = shape[key]
    if (field instanceof ZodObject) {
      map[key] = getRequiredFields(field)
    } else {
      map[key] = !isOptional(field)

    }
  }
  return map
}

And it works, at least for two nesting levels, but the typing is wrong. I can't come up with the right types, so I'm seeking help either with this typing or another solution to this problem.


Solution

  • You can use conditional types for proper deep typing

    import { ZodObject, ZodTypeAny, ZodOptional, ZodNullable, ZodType, z } from 'zod'
    
    type RequiredFieldsMap<T> = {
      [K in keyof T]: T[K] extends ZodObject<infer U>
        ? RequiredFieldsMap<U>
        : boolean
    }
    
    function isOptional(schema: ZodTypeAny): boolean {
      return schema.safeParse(undefined).success
    }
    
    export function getRequiredFields<T extends ZodObject<any>>(
      schema: T
    ): RequiredFieldsMap<T['shape']> {
      const shape = schema.shape
      const map: any = {}
    
      for (const key in shape) {
        const field = shape[key]
        map[key] = field instanceof ZodObject
          ? getRequiredFields(field)
          : !isOptional(field)
      }
    
      return map
    }
    

    This works with nested objects and infers correct RequiredFieldsMap .