T
TanStack•5mo ago
eastern-cyan

Complex object Select field, no reasonable defaultValue, and validation

I've got a "Create equipment" form. It's got 2 required selections, which will determine relations with other data. There's a "model" for selecting the equipment model relation, and a "customer" for selecting the customer who owns this instance of that model. Both of the selectors result in field values that are complex object.
<form.AppField
name="model"
validators={{
onBlur: equipmentFormDataSchema().shape.model, // this doesn't type check
}}
children={(field) => {
return <field.ModelSelectorField label="Model" />;
}}
/>
<form.AppField
name="model"
validators={{
onBlur: equipmentFormDataSchema().shape.model, // this doesn't type check
}}
children={(field) => {
return <field.ModelSelectorField label="Model" />;
}}
/>
The equipmentFormDataSchema is defined as
// A "selectable object" is a schema for fields that are populated as a subschema
// object by a Select; in this case, we don't want all of the subschema errors,
// we just want to know when a selection got skipped.
const selectableObject = <T extends z.ZodTypeAny>(schema: T, message: string) =>
schema
.optional()
.catch(undefined)
.refine((x) => !!x, { message });

const equipmentFormDataSchema = (
required_error: string = "Equipment selection is required",
) =>
z.object(
{
serialNumberOrVIN: z
.string({ required_error: "Serial number or VIN is required" })
.nonempty("Serial number or VIN is required"),
plateNumber: z.string().nullish(),
carNumber: z.string().nullish(),
notes: z.string().nullish(),
model: selectableObject(equipmentModelFormDataSchema(), "Model selection is required"),
customer: selectableObject(customerFormDataSchema(), "Customer selection is required"),
},
{ required_error },
);
// A "selectable object" is a schema for fields that are populated as a subschema
// object by a Select; in this case, we don't want all of the subschema errors,
// we just want to know when a selection got skipped.
const selectableObject = <T extends z.ZodTypeAny>(schema: T, message: string) =>
schema
.optional()
.catch(undefined)
.refine((x) => !!x, { message });

const equipmentFormDataSchema = (
required_error: string = "Equipment selection is required",
) =>
z.object(
{
serialNumberOrVIN: z
.string({ required_error: "Serial number or VIN is required" })
.nonempty("Serial number or VIN is required"),
plateNumber: z.string().nullish(),
carNumber: z.string().nullish(),
notes: z.string().nullish(),
model: selectableObject(equipmentModelFormDataSchema(), "Model selection is required"),
customer: selectableObject(customerFormDataSchema(), "Customer selection is required"),
},
{ required_error },
);
All of this mostly accomplishes what I want, but it leaves me with a Typescript error on the onBlur. I'm not sure whether my issue has more to do with failing to understand my requirements, or how to apply Zod to those, or how to combine the Zod validator with TSF.
11 Replies
eastern-cyan
eastern-cyanOP•5mo ago
My ModelSelectorField component eventually calls field.handleChange(model) with a fully-populated model. The component which uses that selector uses some of the data in model to determine which pieces of UI to expose. But ultimately, when I POST the form, I'm only persisting the modelNumber field from that object. Still, I like being able to have the complex data type result from the user selection interaction. This is the Typescript complaint for the onBlur (although it actually works, it doesn't type check):
Types of property 'input' are incompatible.
Type 'unknown' is not assignable to type 'ObjectValue<never, Partial<{ serialNumberOrVIN: string; model?: { name: string; modelNumber: string; typeName: "V_TYPE" | "E_TYPE" | "STOPWATCH" | "SPEEDO"; calPeriodDays?: number | null | undefined; } | undefined; plateNumber?: string | ... 1 more ... | undefined; carNumber?: string | ... 1 more ... | undefined; no...'.
Types of property 'input' are incompatible.
Type 'unknown' is not assignable to type 'ObjectValue<never, Partial<{ serialNumberOrVIN: string; model?: { name: string; modelNumber: string; typeName: "V_TYPE" | "E_TYPE" | "STOPWATCH" | "SPEEDO"; calPeriodDays?: number | null | undefined; } | undefined; plateNumber?: string | ... 1 more ... | undefined; carNumber?: string | ... 1 more ... | undefined; no...'.
continuing-cyan
continuing-cyan•5mo ago
so you start out with no reasonable value, and the complex selector will create the object you need? Is null not a reasonable default value for this? If it is, you could have the schema as nullable, transforming it into non-nullable and continuing with your validation. defaultValues will be of type z.input<typeof schema>, that initial value being null describing no selection if the description confuses you, I'm happy to provide a small example also another thing to note: * Zod needs to be above version 3.24 for standard schema support. * You can pass the schema directly
validators={{
- onBlur: equipmentFormDataSchema().shape.model, // this doesn't type check
+ onBlur: equipmentFormDataSchema() // returns a ZodObject, which is standard schema compliant
}}
validators={{
- onBlur: equipmentFormDataSchema().shape.model, // this doesn't type check
+ onBlur: equipmentFormDataSchema() // returns a ZodObject, which is standard schema compliant
}}
eastern-cyan
eastern-cyanOP•5mo ago
so you start out with no reasonable value, and the complex selector will create the object you need
exactly correct. null seems to be as reasonable a default as undefined which I'm currently using. OK I see that I'm not being very clear on what I've done here. Maybe this is getting to my real problem.
onBlur: equipmentFormDataSchema{}.shape.model,
onBlur: equipmentFormDataSchema{}.shape.model,
This says that for the Model selector field validation, use the equipmentFormDataSchema its model field's schema definition. They're two different schemas, composed. equipmentModelFormDataSchema defines the schema of the model field in the equipmentFormDataSchema. (But the field's schema is also used elsewhere in my app).
continuing-cyan
continuing-cyan•5mo ago
I see. I wasn't aware that you can access subschemas like that in zod, apologies.
exactly correct. null seems to be as reasonable a default as undefined which I'm currently using.
And do you initiate defaultValues as z.input<> and give it that default for the model field?
eastern-cyan
eastern-cyanOP•5mo ago
haha no apologies needed; I appreciate the interaction because it is forcing me to think more deeply about my problem so that I can explain it. Currently, the defaultValues:
const defaultValues: Partial<EquipmentFormData> = {};
const defaultValues: Partial<EquipmentFormData> = {};
and over where the form data schemas are defined (at src/lib/form-data.ts, because they're shared among components):
const equipmentFormDataSchema = (
required_error: string = "Equipment selection is required",
) =>
z.object(
{
serialNumberOrVIN: z
.string({ required_error: "Serial number or VIN is required" })
.nonempty("Serial number or VIN is required"),
plateNumber: z.string().nullish(),
carNumber: z.string().nullish(),
notes: z.string().nullish(),
model: selectableObject(equipmentModelFormDataSchema(), "Model selection is required"),
customer: selectableObject(customerFormDataSchema(), "Customer selection is required"),
},
{ required_error },
);

type EquipmentFormData = z.infer<ReturnType<typeof equipmentFormDataSchema>>;
const equipmentFormDataSchema = (
required_error: string = "Equipment selection is required",
) =>
z.object(
{
serialNumberOrVIN: z
.string({ required_error: "Serial number or VIN is required" })
.nonempty("Serial number or VIN is required"),
plateNumber: z.string().nullish(),
carNumber: z.string().nullish(),
notes: z.string().nullish(),
model: selectableObject(equipmentModelFormDataSchema(), "Model selection is required"),
customer: selectableObject(customerFormDataSchema(), "Customer selection is required"),
},
{ required_error },
);

type EquipmentFormData = z.infer<ReturnType<typeof equipmentFormDataSchema>>;
continuing-cyan
continuing-cyan•5mo ago
tanstack form explicitly works with the input type, not the infer type from zod. Try and see if that fixes it
eastern-cyan
eastern-cyanOP•5mo ago
👀
continuing-cyan
continuing-cyan•5mo ago
infer vs. input tends to have problems with unions, refinements or transforms
eastern-cyan
eastern-cyanOP•5mo ago
Yes, your recommendation helps alot. And now I can see that there's another issue (which I do already see how to fix). The follow-up issue is that I've got alot of places in the component which refer to form state, e.g.
const modelType = useStore(
form.store,
(state) => (state.values.model as EquipmentModelSummary | undefined)?.typeName ?? null,
);
const modelType = useStore(
form.store,
(state) => (state.values.model as EquipmentModelSummary | undefined)?.typeName ?? null,
);
As you can see, I've had to add a type assertion because the type of state.values.model is the untyped Object, {}. Which points my attention back to the definition of equipmentFormDataSchema. The model field on this "equipment form data schema" doesn't really express my intent, which is that the thing either is "guaranteed" to be a well-formed object (of a "summary" type defined by the data-access layer), or it's undefined (or null, I don't think it matters too much which one). I don't actually need to reuse the form data schema of the "model form data schema" because that's not what's being validated here. I suspected my problem had to do with an incomplete / incorrect "mental model", and discussing with you has helped me find that.
continuing-cyan
continuing-cyan•5mo ago
another thing that may be the cause is that your defaultValues is a partial of the form values ideally it should just be of the schema type so that necessity is probably because of what you said, where some values are not nullable when they should be
eastern-cyan
eastern-cyanOP•5mo ago
ok, that made a big difference, including readability. Replaced the selectable fields with z.object({}, {required_error: "X selection is required"}).passthrough().nullable(), and onblur: itsSchema.shape.x.unwrap() Thanks again for the QSO

Did you find this page helpful?