Search code examples
typescripttypescript-genericszod

Can't z.infer<> in a generic typescript parameter when schema contains a union type?


So I'm trying to make a helper that will allow me to easily create empty/initial form values from a zod schema that will validate the whole/complete form. In other words I want the schema to require every field to be populated, but initial values might be nullable...

Anyway I am confused by an issue demonstrated in this sandbox

With the following code:

const schema = z.object({
    deep: z.object({
        union: z.enum(['a', 'b'])
    })
})

function makeObj<D extends Partial<z.infer<typeof schema>>, S extends z.ZodTypeAny>(schema: S, v: D): z.ZodType<DeepReplace<z.infer<S>, D>> {
    return schema as z.ZodType<DeepReplace<z.infer<S>, D>>
}

const obj = makeObj(schema, {
    deep: {
        union: 'a'
    }
}).parse({})

obj is correctly typed:

const obj: {
    deep: {
        union: "a";
    };
}

image

But if I replace the function declaration with this line:

function makeObj<D extends Partial<z.infer<S>>, S extends z.ZodTypeAny>(schema: S, v: D): z.ZodType<DeepReplace<z.infer<S>, D>> {
    return schema as z.ZodType<DeepReplace<z.infer<S>, D>>
}
const obj = makeObj(schema, {
    deep: {
        union: 'a'
    }
}).parse({})

Now type inference is broken:

const obj: {
    deep: {
        union: null;
    } | {
        union: "a" | "b";
    };
}

image

Unless, I have found I put "as const" on the second argument:

const obj = makeObj(schema, {
    deep: {
        union: 'a'
    }
} as const).parse({})
  • It appears that this is only a problem when union types are involved
  • I'd love not to have to bother with the as const everywhere.
  • And mostly I'd like to understand why using z.infer<> is the source of the issue!

Thanks!

-Morgan


Solution

  • I think the issue is with your DeepReplace type in conjunction with the Partial you're wrapping the z.infer<typeof S> with. The Partial adds an | undefined to each value in the inferred type and DeepReplace is a distributed conditional type so it will map DeepReplace onto first the key (which is where the deep: "a" | "b" comes from) and then the undefined which is where the | null comes from.

    Playground with null replaced so you can see where the null comes from. It's still a little hard to see what's going on even in this reduced example, but another reason I'm convinced it's the Partial + the distributed conditional type is because adding as const fixes the issue.

    When you added as const the inferred type for D becomes exactly the value you passed in and the Partial is unwrapped because the exact type is known which means there are no | undefineds tacked on.

    I'm not 100% sure what you want the behavior of your DeepReplace type to be, but you can replace some of the null's with nevers to prune the conditional distributions that you don't want. For example,

    export type DeepReplace<T, U> =
      T extends Date ? U extends Date ? U : never :
        T extends object 
          ? U extends object 
            ? {
              [K in keyof T]: K extends keyof U 
                ? DeepReplace<T[K], U[K]> 
                : T[K] extends object ? DeepReplace<T[K], { [k: string]: null }> 
                  : T[K] | null
            } 
            : never
          : U
    

    This doesn't have the | union: null for the case you described. The inner T[K] | null and DeepReplace<T[K], { [k: string]: null }> I left alone because I'm not sure what they're meant to be doing, but you could consider replacing those arms with never.