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";
};
}
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";
};
}
Unless, I have found I put "as const" on the second argument:
const obj = makeObj(schema, {
deep: {
union: 'a'
}
} as const).parse({})
as const
everywhere.Thanks!
-Morgan
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 | undefined
s 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
.