Problem

Same field returns different JSON types depending on data:

"min_price": 0
"min_price": {"excl_vat": 50.5, "incl_vat": 60.63}
"min_price": null

Solution: custom UnmarshalJSON, try simplest format first

func (p *PriceInput) UnmarshalJSON(data []byte) error {
    if string(data) == "null" {
        p.ExclVAT, p.InclVAT = 0, 0
        return nil
    }
    var num float64
    if err := sonic.Unmarshal(data, &num); err == nil {
        p.ExclVAT, p.InclVAT = num, num
        return nil
    }
    type Alias PriceInput
    aux := &struct{ *Alias }{Alias: (*Alias)(p)}
    return sonic.Unmarshal(data, aux)
}

Type alias pattern prevents infinite recursion

Without the alias, calling sonic.Unmarshal(data, p) inside UnmarshalJSON calls itself forever. The type Alias creates a new type with no custom unmarshaler, so the fallback uses the default behavior.

MongoDB ObjectId: string or populated object

Same pattern — try string first, fall back to object:

func (u *InternalUserIDInput) UnmarshalJSON(data []byte) error {
    var id string
    if err := sonic.Unmarshal(data, &id); err == nil {
        u.ID = id
        return nil
    }
    type Alias InternalUserIDInput
    aux := &struct{ *Alias }{Alias: (*Alias)(u)}
    return sonic.Unmarshal(data, aux)
}

Schema vs reality

Always test against the actual API response, not the schema docs. A serialization layer (e.g. Mongoose’s toJSON()) may convert the type before it leaves the server — schema says number, wire sends ISO 8601 string.