T
TanStack•4mo ago
sunny-green

TypeScript issue if the (zod) schema is a union or is using superRefine?

Hi, I created a minimal reproducible example of a ecommerce form I am working on: https://github.com/thobas-dnvgl/tanstack-demo The ieda is to ask for the mailing address and have a choice (off by default) to enter another address for the billing address. I thought about using a discriminated union but I get a TypeScript error. I then used .superRefine but I still get a TypeScript error (all of that is documented in the README in the repository). The form works as expected and the error disappears if I do not use satisfies TypeData on the defautlValues but it's less type safe. Any idea what's going on? Bug in TanStrack Form? Zod schema that could be improved? Thanks!
GitHub
GitHub - thobas-dnvgl/tanstack-demo: TanStack Form demo with zod an...
TanStack Form demo with zod and radio buttons (yes the name of the repo could have been better) - thobas-dnvgl/tanstack-demo
5 Replies
conscious-sapphire
conscious-sapphire•4mo ago
Hi! Tanstack form uses the input type from standard schemas. Zod implements this by using the type z.input<typeof yourSchema>. Your code appears to be using z.infer which will create the wrong type. As for the defaultValues, instead of satisfying TypeData, you can cast it as TypeData straight away like so:
- const defaultValues = {
- separateBillingAddress: false,
- mailingAddress: "",
- billingAddress: "",
- } satisfies AddressesData;
+ const defaultValues: AddressData = {
+ separateBillingAddress: false,
+ mailingAddress: '',
+ billingAddress: '',
+ }
- const defaultValues = {
- separateBillingAddress: false,
- mailingAddress: "",
- billingAddress: "",
- } satisfies AddressesData;
+ const defaultValues: AddressData = {
+ separateBillingAddress: false,
+ mailingAddress: '',
+ billingAddress: '',
+ }
Let me know if the fix above solved your issue. A note for future schemas: zod unions will need to be exhaustive. Zod is strict with passthrough properties, but your form will always have some value in the conditional properties. Widen the restriction to let it pass through.
- z.union([
- z.object({ type: z.literal('A') }),
- z.object({ type: z.literal('B'), b: z.string().email() })
- ])
+ z.union([
+ z.object({ type: z.literal('A'), b: z.string() }),
+ z.object({ type: z.literal('B'), b: z.string().email() })
+ ])
- z.union([
- z.object({ type: z.literal('A') }),
- z.object({ type: z.literal('B'), b: z.string().email() })
- ])
+ z.union([
+ z.object({ type: z.literal('A'), b: z.string() }),
+ z.object({ type: z.literal('B'), b: z.string().email() })
+ ])
sunny-green
sunny-greenOP•4mo ago
Thanks, I've used satisfies because I remembered a PR switching from it in the docs but it looks like it was reverted to casting like your suggest so I will use that. As far as I can tell, the union I tried (and tried again) is exhaustive?
export const AddressesDataSchema = z.discriminatedUnion(
"separateBillingAddress",
[
z.object({
separateBillingAddress: z.literal(false),
mailingAddress: z.string().trim().min(1),
billingAddress: z.string().optional(),
}),
z.object({
separateBillingAddress: z.literal(true),
mailingAddress: z.string().trim().min(1),
billingAddress: z.string().trim().min(1),
}),
]
);
export type AddressesData = z.input<typeof AddressesDataSchema>;

// ....

function App() {
const defaultValues: AddressesData = {
separateBillingAddress: false,
mailingAddress: "",
billingAddress: "",
};
const form = useForm({
defaultValues,
validators: {
onChange: AddressesDataSchema,
},
onSubmit: async ({ value }) => {
console.log("Success", value);
},
});
export const AddressesDataSchema = z.discriminatedUnion(
"separateBillingAddress",
[
z.object({
separateBillingAddress: z.literal(false),
mailingAddress: z.string().trim().min(1),
billingAddress: z.string().optional(),
}),
z.object({
separateBillingAddress: z.literal(true),
mailingAddress: z.string().trim().min(1),
billingAddress: z.string().trim().min(1),
}),
]
);
export type AddressesData = z.input<typeof AddressesDataSchema>;

// ....

function App() {
const defaultValues: AddressesData = {
separateBillingAddress: false,
mailingAddress: "",
billingAddress: "",
};
const form = useForm({
defaultValues,
validators: {
onChange: AddressesDataSchema,
},
onSubmit: async ({ value }) => {
console.log("Success", value);
},
});
I still get:
Type '{ separateBillingAddress: true; mailingAddress: string; billingAddress: string; }' is not assignable to type '{ separateBillingAddress: false; mailingAddress: string; billingAddress?: string | undefined; }'.
Types of property 'separateBillingAddress' are incompatible.
Type 'true' is not assignable to type 'false'.ts(2322)
Type '{ separateBillingAddress: true; mailingAddress: string; billingAddress: string; }' is not assignable to type '{ separateBillingAddress: false; mailingAddress: string; billingAddress?: string | undefined; }'.
Types of property 'separateBillingAddress' are incompatible.
Type 'true' is not assignable to type 'false'.ts(2322)
But now I have an alternative. 🙂
conscious-sapphire
conscious-sapphire•4mo ago
strange ... I'll boot up a stackblitz version of this repro later so it's quicker to test. Is it alright if I copy the code?
sunny-green
sunny-greenOP•4mo ago
of course, this repository is not a real app. it's a minimum viable example and has been made for experiments like these 🙂
conscious-sapphire
conscious-sapphire•4mo ago
How strange ... It seems like TypeScript is getting confused with the union and tries to 'resolve' it before it even compares with the schema.
const defaultValues: FormValues = {
separateBillingAddress: false,
mailingAddress: '',
billingAddress: undefined,
}; // Error! incompatible with the zod schema

const defaultValues: FormValues = {
separateBillingAddress: false,
mailingAddress: '',
billingAddress: undefined,
} as FormValues; // Works ?
const defaultValues: FormValues = {
separateBillingAddress: false,
mailingAddress: '',
billingAddress: undefined,
}; // Error! incompatible with the zod schema

const defaultValues: FormValues = {
separateBillingAddress: false,
mailingAddress: '',
billingAddress: undefined,
} as FormValues; // Works ?
It might be type narrowing (TypeScript infers that defaultValues will never have separateBillingAddress: true before being passed to the hook and therefore is only one of the two possible unions). But I don't understand why that's causing trouble

Did you find this page helpful?