I'm working on a form using react-form hooks
and using zod
for validation. I'm using zod discriminationUnion
to dynamically show different fields based on a selection named modes
with three options: FCL, LCL, and BULK.
I have 3 zod schemas for FCL
, LCL
, and BULK
, and they also share common fields like from
, to
, comment
, firstname
, lastname
, email
, and more.
Zod Schema:
// Import the 'z' library, which is used for schema validation.
import * as z from "zod";
// Define a schema for Full Container Load (FCL) mode.
const fclSchema = z.object({
from: z.object({
address: z.string(),
latitude: z.number().min(-90).max(90),
longitude: z.number().min(-180).max(180),
}),
to: z.object({
address: z.string(),
latitude: z.number().min(-90).max(90),
longitude: z.number().min(-180).max(180),
}),
modes: z.literal('FCL'), // Specifies the mode as 'FCL'.
f_container_type: z.enum(["20", "40", "40HC"]), // Specifies container types.
f_quantity: z.coerce.number().min(1).max(32767),
f_weight: z.coerce.number(),
f_unit: z.enum(["kg", "lbs"]),
comment: z.string().min(3).max(160).optional(), // Optional comment.
hsn: z.enum(["1", "2", "3"]), // HSN enum.
date: z.date(), // Date field.
ico: z.enum(["FCA", "FOB", "CFR", "DDU", "DAP", "DDP"]), // ICO enum.
insurance: z.boolean(),
custom: z.boolean(),
firstName: z.string().min(3).max(18), // First name validation.
lastName: z.string().min(3).max(18), // Last name validation.
phone: z.string().min(10).max(15), // Phone number validation.
email: z.string().email(), // Email validation.
company: z.string().min(3).max(18), // Company name validation.
});
// Define a schema for Less than Container Load (LCL) mode.
const lclSchema = z.object({
from: z.object({
address: z.string(),
latitude: z.number().min(-90).max(90),
longitude: z.number().min(-180).max(180),
}),
to: z.object({
address: z.string(),
latitude: z.number().min(-90).max(90),
longitude: z.number().min(-180).max(180),
}),
modes: z.literal('LCL'), // Specifies the mode as 'LCL'.
l_container_type: z.enum(["pallete", "boxes", "package", "bag"]), // Container types.
l_load: z.array(
z.object({
l_wid: z.coerce.number().nullish(),
l_hgt: z.coerce.number().nullish(),
l_lgt: z.coerce.number().nullish(),
l_volume_unit: z.enum(["cm", "in"]),
l_quantity: z.coerce.number().nullish(),
l_weight: z.coerce.number().nullish(),
l_weight_unit: z.enum(["kg", "lbs"]),
})
).refine(data => data.every(load => load.l_quantity !== null && load.l_quantity !== undefined && load.l_quantity >= 1), {
message: 'Quantity must be at least 1',
path: ['l_load', 'l_quantity'], // Validation for load quantity.
}),
comment: z.string().min(3).max(160), // Comment validation.
hsn: z.enum(["1", "2", "3"]), // HSN enum.
date: z.date(), // Date field.
ico: z.enum(["FCA", "FOB", "CFR", "DDU", "DAP", "DDP"]), // ICO enum.
insurance: z.boolean(),
custom: z.boolean(),
firstName: z.string().min(1).max(18), // First name validation.
lastName: z.string().min(1).max(18), // Last name validation.
phone: z.string().min(10).max(14), // Phone number validation.
email: z.string().email(), // Email validation.
company: z.string().min(1).max(18), // Company name validation.
});
// Define a schema for Bulk mode.
const bulkSchema = z.object({
from: z.object({
address: z.string(),
latitude: z.number().min(-90).max(90),
longitude: z.number().min(-180).max(180),
}),
to: z.object({
address: z.string(),
latitude: z.number().min(-90).max(90),
longitude: z.number().min(-180).max(180),
}),
modes: z.literal('BULK'), // Specifies the mode as 'BULK'.
b_type: z.enum([
"GC",
"RC",
"DG",
"OOG",
"BC",
"C",
"P/AC",
"P/CT",
"P/CC",
"P/GC",
"S/HL",
"S/L",
"S/R",
"S/Ro",
"S/WC",
]),
b_load: z.array(
z.object({
b_wid: z.coerce.number().nullish(),
b_hgt: z.coerce.number().nullish(),
b_lgt: z.coerce.number().nullish(),
b_volume_unit: z.enum(["cm", "in"]),
b_quantity: z.coerce.number().nullish(),
b_weight: z.coerce.number().nullish(),
b_weight_unit: z.enum(["kg", "lbs"]),
})
),
b_loading_rate: z.coerce.number(),
b_loading_unit: z.enum(["kg/day", "lbs/day"]),
b_discharge_rate: z.coerce.number(),
b_discharge_unit: z.enum(["kg/day", "lbs/day"]),
comment: z.string().min(3).max(160), // Comment validation.
hsn: z.enum(["1", "2", "3"]), // HSN enum.
date: z.date(), // Date field.
ico: z.enum(["FCA", "FOB", "CFR", "DDU", "DAP", "DDP"]), // ICO enum.
insurance: z.boolean(),
custom: z.boolean(),
firstName: z.string().min(1).max(18), // First name validation.
lastName: z.string().min(1).max(18), // Last name validation.
phone: z.string().min(10).max(24), // Phone number validation.
email: z.string().email(), // Email validation.
company: z.string().min(1).max(18), // Company name validation.
});
// Create a discriminated union schema that selects the appropriate mode schema based on the 'modes' field.
export const profileFormSchema = z.discriminatedUnion("modes", [
fclSchema,
lclSchema,
bulkSchema,
]);
the Problem is all the common elements are showing error messages just fine. However, when it comes to the dynamic element, using {errors.f_quantity && <span className="text-red-500">{errors.f_quantity.message}</span>
} is causing an error.
Property 'f_quantity' does not exist on type 'FieldErrors<{ from: { address: string; latitude: number; longitude: number; }; date: Date; custom: boolean; to: { address: string; latitude: number; longitude: number; }; modes: "FCL"; f_container_type: "20" | ... 1 more ... | "40HC"; ... 11 more ...; comment?: string | undefined; } | { ...; } | { ...; }>'.
Property 'f_quantity' does not exist on type 'Partial<FieldErrorsImpl<{ from: { address: string; latitude: number; longitude: number; }; date: Date; custom: boolean; to: { address: string; latitude: number; longitude: number; }; modes: "LCL"; comment: string; ... 9 more ...; l_load: { ...; }[]; }>> & { ...; }'.ts(2339)
Below code this displays dynamic element when mode
===FCL
here im using watch hook to monitor mode
selection element( const watchMode = watch("modes");
)
Code(i have marked error lines below):
{/* SELECT==FCL */}
{watchMode === "FCL" && (
<div>
<div className="sm:flex sm:space-x-4 ">
<div className="sm:w-1/2 mt-5">
<Label className="mb-2">Container type</Label>
<Controller
name="f_container_type"
control={control}
render={({ field }) => (
<Select
value={field.value}
onValueChange={field.onChange}
>
<SelectTrigger>
<SelectValue placeholder="Container type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="20">20' Standard</SelectItem>
<SelectItem value="40">40' Standard</SelectItem>
<SelectItem value="40HC">
40' High cube
</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="sm:w-1/2 mt-5">
<Label className="mb-2">Quantity</Label>
<Input
type="number"
placeholder="Enter the Quantity"
{...register("f_quantity")}
/>
//HERE----------------------------------------------->
{errors.f_quantity && <span className="text-red-500">{errors.f_quantity.message}</span>}
</div>
</div>
<div className="sm:w-1/2 mt-5">
<Label className="mb-2">Weight</Label>
<div className="relative max-w-[400px]">
<Input
type="number"
className="py-3 px-4 pr-16 block w-full border-none shadow-sm rounded-md text-sm"
placeholder="Enter the Weight"
{...register("f_weight")}
/>
//HERE-------------------------------------------------------------------------->
{errors.f_weight && <span className="text-red-500">{errors.f_weight.message}</span>}
<div className="absolute inset-y-0 right-0 flex items-center z-20 pr-4">
<Controller
name="f_unit"
control={control} // make sure to define 'control' in your useForm hook
defaultValue="kg"
render={({ field }) => (
<Select
value={field.value}
onValueChange={field.onChange}
>
<SelectTrigger className=" border-none text-">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="kg">KG</SelectItem>
<SelectItem value="lbs">LBS</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
</div>
</div>
</div>
)}
codesandbox
https://codesandbox.io/p/sandbox/github/Amith-AG/discriminatedunion_react_form/tree/master
In typescript, you cannot type-guard with different references. so you have to check mode
value on errors
object itself.
if(errors.mode === "FCL") {
errors.f_quantity // no errors
}
if you really want to use watch
function, you can check both together
{watchMode === "FCL" && errors.mode === "FCL" && (
errors.f_quantity // no errors, again :)
)}
Edit:
I Checked your code and looks like the errors object does not hold the original value and only holds data about the error.
So, to solve the issue you have, we can create a type-guard function to check if the value is what we want. here is how:
const isModeOf = <TMode extends FormValues["modes"]>(err: typeof errors, mode: TMode): err is FieldErrors<Extract<FormValues, {modes: TMode}>> => {
return watchMode === mode
}
// usage
<>
{isModeOf(errors, "FCL") && <>
{errors.f_quantity // no errors }
</>}
</>
you can see the code at my codesandbox
If you want to know more type guard functions, you can read from typescript documentation