Stphane
Stphane

Reputation: 3466

How to access Zod DiscriminatedUnion specific branch property from validation "refine" function?

Here is some code in charge of validating a form schema on our front-end application.

We are having trouble with Zod library accessing a discriminatedUnion specific branch's property from within refine() validation callback. The endDate property is absent from auto-suggest completion, the property is being underlined in red by Typescript language server saying "Property 'endDate' does not exist on type …".

How are specific branches properties supposed to be accessed in this context?
export const formSchema = z.object({
  // Few fields here ...
  contract: z.object({
    startDate: z.string().describe('text'),
    contractType: z.enum(['III', 'DDD']).describe('select'),
    // Few other fields here ...
    specificationContrat: z.discriminatedUnion('contractType', [
      z.object({
        contractType: z.literal('III'),
        ...someSchema.shape,
      }),
      z.object({
        contractType: z.literal('DDD'),
        endDate: z.string().describe('text'),
        ...someSchema.shape,
      })
    ]),
  })
  .refine(
    (contract) => {
      return contract.contractType === 'III' ||
        (contract.contractType === 'DDD' && new Date(contract.specificationContrat.endDate) > new Date(contract.startDate));
        // "Property 'endDate' does not exist on type …"______________________________^
    },
    '"endDate" has to be anterior to "startDate"'
  )
});

Upvotes: 0

Views: 126

Answers (2)

Pavlo Sobchuk
Pavlo Sobchuk

Reputation: 1564

Actually, if I'm narrowing a type, I can then define the type with z.infer and simplify the refine function:

const IIIContractSchema = z.object({
  contractType: z.literal("III"),
  // ... other properties
});

const DDDContractSchema = z.object({
  contractType: z.literal("DDD"),
  endDate: z.string().describe("text"),
  // ... other properties
});

const specificationContratSchema = z.discriminatedUnion("contractType", [
  IIIContractSchema,
  DDDContractSchema,
]);

// Infer TypeScript types from Zod schemas
type IIIContract = z.infer<typeof IIIContractSchema>;
type DDDContract = z.infer<typeof DDDContractSchema>;
type SpecificationContrat = z.infer<typeof specificationContratSchema>;

// Now define your form schema
export const formSchema = z.object({
  contract: z
    .object({
      startDate: z.string().describe("text"),
      contractType: z.enum(["III", "DDD"]).describe("select"),
      specificationContrat: specificationContratSchema,
    })
    .refine(
      (contract): boolean => {
        if (contract.contractType === "III") {
          return true;
        } else if (contract.contractType === "DDD") {
          const spec = contract.specificationContrat as DDDContract;
          return new Date(spec.endDate) > new Date(contract.startDate);
        }
        return false;
      },
      { message: '"endDate" must be after "startDate"' }
    ),
});

Upvotes: 1

Pavlo Sobchuk
Pavlo Sobchuk

Reputation: 1564

I can only think of two ways, both of them are clumsy and related to type narrowing to help typescript deal with inference and definitions:

Narrow it inline:

.refine(
  (contract) => {
    if (contract.contractType === "DDD") {
      const spec = contract.specificationContrat;
      if (spec.contractType === "DDD") {
        // TypeScript now knows 'spec' is of type '{ contractType: "DDD"; endDate: string }'
        return new Date(spec.endDate) > new Date(contract.startDate);
      }
    }
    // For other contract types or if no validation is needed
    return true;
  },
  { message: '"endDate" has to be after "startDate"' }
),

or create a type guard:

    .refine(
          (contract) => {
            // Type guard to check if contract.specificationContrat is of type 'DDD'
            const isDDDContract = (
              spec: typeof contract.specificationContrat
            ): spec is { contractType: "DDD"; endDate: string } =>
              spec.contractType === "DDD";
    
            return (
              contract.contractType === "III" ||
              (isDDDContract(contract.specificationContrat) &&
                new Date(contract.specificationContrat.endDate) >
                  new Date(contract.startDate))
            );
          },
          { message: '"endDate" has to be after "startDate"' }
),

Upvotes: 1

Related Questions