reactjstypescriptzodreact-forms

How i can implement error validation message in React Form with Zod Schema using discrimination union?


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.

Demo

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 modeselection 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


Solution

  • 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