colinhacks / zod

TypeScript-first schema validation with static type inference
https://zod.dev
MIT License
33.09k stars 1.15k forks source link

zod parsing bug #3608

Open realtebo opened 2 months ago

realtebo commented 2 months ago

This is my complex object zod definition

import { z } from 'zod'
import { FieldDataType } from '../../enums/filed-data-type'
import { FieldDataChromoShow } from '../../enums/field-data-chromo-show'
import { EventCategory } from '../../enums/event-category'

const genericAlarmEventFieldDataPayloadSchema = z.object({
    NtpError: z.coerce.boolean(),
    DeviceNotFound: z.coerce.boolean(),
    MasterNotFound: z.coerce.boolean(),
})

const hammamAlarmEventFieldDataPayloadSchema = z.object({
    BoilerFullTooSoon: z.coerce.boolean(),
    EvLoad: z.coerce.boolean(),
    EvDrain: z.coerce.boolean(),
    VinBus: z.coerce.boolean(),
    Ntc: z.coerce.boolean(),
    Fan: z.coerce.boolean(),
    Slave: z.coerce.boolean(),
    HeaterFail: z.coerce.boolean(),
    Capacitive: z.coerce.boolean(),
    CabTemp: z.coerce.boolean(),
    Door: z.coerce.boolean(),
})

const saunaAlarmEventFieldDataPayloadSchema = z.object({
    StoveTemp: z.coerce.boolean(),
    VinBus: z.coerce.boolean(),
    Ntc: z.coerce.boolean(),
    ThermalProtection: z.coerce.boolean(),
    CabTemp: z.coerce.boolean(),
    Potentiometer: z.coerce.boolean(),
})

const eccIotInfoSchema = z.object({
    Id: z.string(),
    Ts: z.coerce.date(),
    Type: z.nativeEnum(FieldDataType),
    HostId: z.string(),
    Payload: saunaAlarmEventFieldDataPayloadSchema
       .or(genericAlarmEventFieldDataPayloadSchema)
       .or(hammamAlarmEventFieldDataPayloadSchema),
})

export const rawEventFieldDataSchema = z.object({
    App: z.string(),
    Cat: z.nativeEnum(EventCategory),
    EccIOTEvent: eccIotInfoSchema,
})

export type RawEventFieldData = z.infer<typeof rawEventFieldDataSchema>

When I use this definition, in my code, zod parses it wrongly

 const body = { 
                "App": "EccIOT", 
                "Cat": "Event", 
                "EccIOTEvent":  
                    {  
                    "Id": "ecc-D8E39657C", 
                    "Ts": "2020/02/28 10:14:10", 
                    "Type":"HAlarm", 
                    "HostId":"0A229332",
                    "Payload":{ 
                        "BoilerFullTooSoon" : 0, 
                        "EvLoad": 0, 
                        "EvDrain": 0, 
                        "VinBus": 0, 
                        "Ntc": 0, 
                        "Fan": 0, 
                        "Slave": 0, 
                        "HeaterFail": 0, 
                        "Capacitive": 0, 
                        "CabTemp": 0, 
                        "Door": 0 
                    } 
                }  
            }

const rawData: RawEventFieldData = rawEventFieldDataSchema.parse(body)

console.log(rawData);

In the console I found this:

{
  App: 'EccIOT',
  Cat: 'Event',
  EccIOTEvent: {
    Id: 'ecc-D8E39657C',
    Ts: 2020-02-28T10:14:10.000Z,
    Type: 'HAlarm',
    HostId: '0A229332',
    Payload: {
      StoveTemp: false,
      VinBus: false,
      Ntc: false,
      ThermalProtection: false,
      CabTemp: false,
      Potentiometer: false
    }
  }
}

What am I doing wrong ?

Zod version is 3.23.8

Compiler options already includes

"compilerOptions": {
       "strict": true   
   }
eswarty commented 2 months ago

Hi! Not totally sure what is causing the problem with the union type, but I think a discriminated union might work instead, e.g.:

const hammamAlarmEventFieldDataPayloadSchema = z.object({
  type: z.literal("hamman"),
  BoilerFullTooSoon: z.coerce.boolean(),
  EvLoad: z.coerce.boolean(),
  EvDrain: z.coerce.boolean(),
  VinBus: z.coerce.boolean(),
  Ntc: z.coerce.boolean(),
  Fan: z.coerce.boolean(),
  Slave: z.coerce.boolean(),
  HeaterFail: z.coerce.boolean(),
  Capacitive: z.coerce.boolean(),
  CabTemp: z.coerce.boolean(),
  Door: z.coerce.boolean(),
});

const saunaAlarmEventFieldDataPayloadSchema = z.object({
  type: z.literal("sauna"),
  StoveTemp: z.coerce.boolean(),
  VinBus: z.coerce.boolean(),
  Ntc: z.coerce.boolean(),
  ThermalProtection: z.coerce.boolean(),
  CabTemp: z.coerce.boolean(),
  Potentiometer: z.coerce.boolean(),
});

const eccIotInfoSchema = z.object({
  Id: z.string(),
  Ts: z.coerce.date(),
  Type: z.nativeEnum(FieldDataType),
  HostId: z.string(),
  Payload: z.discriminatedUnion("type", [
    saunaAlarmEventFieldDataPayloadSchema,
    genericAlarmEventFieldDataPayloadSchema,
    hammamAlarmEventFieldDataPayloadSchema,
  ]),
});

export const rawEventFieldDataSchema = z.object({
  App: z.string(),
  Cat: z.nativeEnum(EventCategory),
  EccIOTEvent: eccIotInfoSchema,
});

export type RawEventFieldData = z.infer<typeof rawEventFieldDataSchema>;
const body = {
    App: "EccIOT",
    Cat: "Event",
    EccIOTEvent: {
      Id: "ecc-D8E39657C",
      Ts: "2020/02/28 10:14:10",
      Type: "HAlarm",
      HostId: "0A229332",
      Payload: {
        type: "hamman",
        BoilerFullTooSoon: 0,
        EvLoad: 0,
        EvDrain: 0,
        VinBus: 0,
        Ntc: 0,
        Fan: 0,
        Slave: 0,
        HeaterFail: 0,
        Capacitive: 0,
        CabTemp: 0,
        Door: 0,
      },
    },
  };

  const rawData: RawEventFieldData = rawEventFieldDataSchema.parse(body);

  console.log(rawData);
realtebo commented 2 months ago

I installed zod-to-ts to diagnose the problem.

Actually, having used coerce for the boolean, every missing property is mapped as a boolean false. and the ts types are all optionals (there is a ? added after the field name in every coerced field)

We resolved using discriminatedUnion, thanks for the tip!

I kindly suggest to add a tip in the documentation about this behaviour.