colinhacks / zod

TypeScript-first schema validation with static type inference
https://zod.dev
MIT License
33.87k stars 1.18k forks source link

Required key inferred as optional in mutually recursive tree structure #3546

Open michaelsmithxyz opened 5 months ago

michaelsmithxyz commented 5 months ago

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?

maxmarchuk commented 5 months ago

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()).

colin-oos commented 3 months ago

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