Required key inferred as optional in mutually recursive tree structure
Hello! We're working on a project that involves modeling something akin to a generic mutually recursive expression tree with Zod. Here's a simplified example:
import { z } from 'zod';
export type AndNode<TBase> = {
type: 'and',
children: Tree<TBase>[],
};
export type OrNode<TBase> = {
type: 'or',
children: Tree<TBase>[],
};
export type NotNode<TBase> = {
type: 'not',
child: Tree<TBase>,
};
export type Tree<TBase> =
| TBase
| AndNode<TBase>
| OrNode<TBase>
| NotNode<TBase>;
function treeOf<
TBaseSchema extends z.ZodTypeAny,
>(
baseSchema: TBaseSchema,
): {
AndNode: z.ZodType<AndNode<z.infer<TBaseSchema>>>,
OrNode: z.ZodType<OrNode<z.infer<TBaseSchema>>>,
NotNode: z.ZodType<NotNode<z.infer<TBaseSchema>>>,
Tree: z.ZodType<Tree<z.infer<TBaseSchema>>>,
} {
let Tree: z.ZodType<Tree<z.infer<TBaseSchema>>>;
const AndNode = z.object({
type: z.literal('and'),
children: z.lazy(() => Tree.array()),
});
const OrNode = z.object({
type: z.literal('or'),
children: z.lazy(() => Tree.array()),
});
const NotNode = z.object({
type: z.literal('not'),
child: z.lazy(() => Tree),
});
Tree = z.union([
baseSchema,
AndNode,
OrNode,
NotNode,
]);
return {
AndNode,
OrNode,
NotNode,
Tree,
};
}
This example fails to type check because NotNode doesn't satisfy the return type:
Type 'ZodObject<{ type: ZodLiteral<"not">; child: ZodLazy<ZodType<Tree<TypeOf<TBaseSchema>>, ZodTypeDef, Tree<TypeOf<TBaseSchema>>>>; }, "strip", ZodTypeAny, { [k in keyof addQuestionMarks<...>]: addQuestionMarks<...>[k]; }, { [k_1 in keyof baseObjectInputType<...>]: baseObjectInputType<...>[k_1]; }>' is not assignable to type 'ZodType<NotNode<TypeOf<TBaseSchema>>, ZodTypeDef, NotNode<TypeOf<TBaseSchema>>>'.
Types of property '_type' are incompatible.
Type '{ [k in keyof addQuestionMarks<baseObjectOutputType<{ type: ZodLiteral<"not">; child: ZodLazy<ZodType<Tree<TypeOf<TBaseSchema>>, ZodTypeDef, Tree<TypeOf<TBaseSchema>>>>; }>, any>]: addQuestionMarks<...>[k]; }' is not assignable to type 'NotNode<TypeOf<TBaseSchema>>'.
Property 'child' is optional in type '{ [k in keyof addQuestionMarks<baseObjectOutputType<{ type: ZodLiteral<"not">; child: ZodLazy<ZodType<Tree<TypeOf<TBaseSchema>>, ZodTypeDef, Tree<TypeOf<TBaseSchema>>>>; }>, any>]: addQuestionMarks<...>[k]; }' but required in type 'NotNode<TypeOf<TBaseSchema>>'.ts(2322)
test.ts(33, 3): The expected type comes from property 'NotNode' which is declared here on type '{ AndNode: ZodType<AndNode<TypeOf<TBaseSchema>>, ZodTypeDef, AndNode<TypeOf<TBaseSchema>>>; OrNode: ZodType<...>; NotNode: ZodType<...>; Tree: ZodType<...>; }'
It seems like the inferred type for NotNode types child as optional, which it should not be. I've seen issues similar to this reported here before and for the most part, they've been solved by enabling strict mode, but that doesn't seem to work here.
Here's the tsconfig.json I'm using for this example:
{
"compilerOptions": {
"strict": true,
}
}
and the package.json:
{
"dependencies": {
"zod": "^3.23.8"
},
"devDependencies": {
"typescript": "^4.8.4"
}
}
I've also tried constraining TBaseSchema, e.g TBaseSchema extends z.ZodType<object>. This doesn't seem to affect anything.
Is there something incorrect about the way this is being modeled?
I have a very similar issue with "zod": "^3.23.8". regardless of the types i'm using in my schema, it is generating each field as optional (even if i don't say .optional()).
I have a very similar issue with
"zod": "^3.23.8". regardless of the types i'm using in my schema, it is generating each field as optional (even if i don't say.optional()).
Yes same issue for me too with ^3.23.8
Hi @michaelsmithxyz! I'm Dosu and I’m helping the zod team.
This is a well-known TypeScript inference edge case when modeling mutually recursive schemas with Zod, especially in union types. TypeScript struggles to infer required/optional keys correctly in recursive unions, sometimes making required fields (like child in your NotNode) appear optional. This isn't a bug in Zod—it's a tradeoff in how TypeScript handles cyclical inference and unions with recursive types. See this discussion and this explanation.
Workarounds include:
- Explicitly annotating the schema types, e.g. casting your
NotNodeschema asz.ZodType<NotNode<...>> - Splitting schemas into separate declarations to help TypeScript's inference
- Sometimes, using
.or()instead ofz.union()for recursive unions can help - As a last resort, you can manually cast the output type of your schema
Zod 4 brings some improvements to cyclical inference for z.object, but recursive inference in unions/discriminated unions still requires these workarounds (details). If you upgrade, you may see better results, but explicit type annotations are still the most reliable fix for now.
Let me know if you want a concrete code example or if you have a minimal reproduction I can look at! If this answers your question, feel free to close the issue.
To reply, just mention @dosu.
How did I do? Good | Irrelevant | Incorrect | Verbose | Hallucination | Report 🐛 | Other
Working perfectly with Zod 4 recursive object types!
import * as z from "zod/v4";
function treeOf<TBaseSchema extends z.ZodTypeAny>(baseSchema: TBaseSchema) {
const AndNode = z.object({
type: z.literal("and"),
get children() {
return Tree.array();
},
});
const OrNode = z.object({
type: z.literal("or"),
get children() {
return Tree.array();
},
});
const NotNode = z.object({
type: z.literal("not"),
get child() {
return Tree;
},
});
const Tree = z.union([baseSchema, AndNode, OrNode, NotNode]);
return {
AndNode,
OrNode,
NotNode,
Tree,
};
}