zod icon indicating copy to clipboard operation
zod copied to clipboard

v4 inferring discriminated union with `.and` object and recursion

Open delasy opened this issue 6 months ago • 2 comments

This code

import { z } from "zod/v4";

export const elementInputSchema = z
  .discriminatedUnion("type", [
    z.object({
      type: z.literal("CONTAINER"),
    }),
    z.object({
      type: z.literal("SCREEN"),
      config: z.object({ x: z.number(), y: z.number() }),
    }),
  ])
  .and(
    z.object({
      get children(): z.ZodOptional<z.ZodArray<typeof elementInputSchema>> {
        return z.array(elementInputSchema).optional();
      },
    }),
  );

export type ElementInput = z.infer<typeof elementInputSchema>;

for some reason TypeScript sees ElementInput as unknown type. I tried removing return type for get children and it didn't help. it works only if I remove completely .optional() and z.ZodOptional. I tried with nullable and it has the same behavior.

Zod version: 3.25.26 Node version: 24.1.0 TypeScript version: 5.8.3

delasy avatar Jun 14 '25 17:06 delasy

Hi @delasy! I'm Dosu and I’m helping the zod team.

This is a known TypeScript limitation when combining Zod's discriminated unions, recursion, and optional fields: TypeScript often infers the type as unknown in these cases, especially when the recursion is not broken up with z.lazy() or similar constructs. This happens because TypeScript's type system can't resolve the recursive type chain, particularly when .optional() or .nullable() is involved in the recursion path, so it falls back to unknown to avoid infinite recursion in type inference (source, source).

The recommended workaround is to use z.lazy() to break the recursion and help TypeScript infer the type correctly. Here’s how you can refactor your schema:

import { z } from "zod/v4";

const elementInputSchema: z.ZodType<any> = z.lazy(() =>
  z
    .discriminatedUnion("type", [
      z.object({
        type: z.literal("CONTAINER"),
      }),
      z.object({
        type: z.literal("SCREEN"),
        config: z.object({ x: z.number(), y: z.number() }),
      }),
    ])
    .and(
      z.object({
        children: z.lazy(() => z.array(elementInputSchema).optional()),
      })
    )
);

export type ElementInput = z.infer<typeof elementInputSchema>;

Notice that both the schema and the children property use z.lazy. This breaks the cycle for TypeScript and allows proper inference (source). If you still get unknown, try adding an explicit type annotation after the schema definition, as sometimes this nudges TypeScript into resolving the type (source).

Let me know if this solves your issue or if you need a more specific example! If this works, please close the issue.

To reply, just mention @dosu.


How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other  Join Discord Share on X

dosubot[bot] avatar Jun 14 '25 17:06 dosubot[bot]

this does not solve the problem

delasy avatar Jun 14 '25 17:06 delasy