Skip to content

coerceFormValue causes stack overflow with Zod v4 getter-based recursive schemas #1127

@greg-hoarau

Description

@greg-hoarau

Describe the bug and the expected behavior

When using coerceFormValue from @conform-to/zod/v4/future with a recursive Zod schema that uses the getter pattern recommended by Zod v4, the function enters an infinite recursion and throws a "Maximum call stack size exceeded" error.

The enableTypeCoercion function has logic to handle z.lazy() for recursive schemas by caching results to prevent infinite recursion. However, the getter-based approach recommended by Zod v4 bypasses this caching mechanism.

Conform version

v1.14.1

Steps to Reproduce the Bug or Issue

  1. Create a recursive schema using Zod v4's recommended getter pattern:
import { z } from 'zod'
import { coerceFormValue } from '@conform-to/zod/v4/future'

const ConditionNodeSchema = z.object({
  type: z.literal('condition'),
  value: z.string(),
})

const LogicalGroupNodeSchema = z.object({
  type: z.literal('group'),
  operator: z.enum(['AND', 'OR']),
  // Getter pattern recommended by Zod v4: https://zod.dev/api#recursive-objects
  get children(): z.ZodArray<z.ZodDiscriminatedUnion<[typeof LogicalGroupNodeSchema, typeof ConditionNodeSchema]>> {
    return z.array(z.discriminatedUnion('type', [LogicalGroupNodeSchema, ConditionNodeSchema]))
  },
})

// This throws: "Maximum call stack size exceeded"
const schema = coerceFormValue(
  z.object({
    filter: LogicalGroupNodeSchema,
  })
)
  1. Run the code - it will throw a stack overflow error.

What browsers are you seeing the problem on?

Chrome

Screenshots or Videos

No response

Additional context

My workaround is to wrap the recursive reference in z.lazy() inside the getter:

const LogicalGroupNodeSchema = z.object({
  type: z.literal('group'),
  operator: z.enum(['AND', 'OR']),
  get children(): z.ZodArray<z.ZodLazy<z.ZodDiscriminatedUnion<[typeof LogicalGroupNodeSchema, typeof ConditionNodeSchema]>>> {
    // Adding z.lazy() inside the getter allows coerceFormValue to work
    return z.array(z.lazy(() => z.discriminatedUnion('type', [LogicalGroupNodeSchema, ConditionNodeSchema])))
  },
})

This workaround combines both patterns to satisfy TypeScript and enable proper caching in coerceFormValue.

Metadata

Metadata

Labels

bugSomething isn't working

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions