Accept single value or array

?ids=1 and ?ids=1&ids=2 both produce the same shape:

evseIds: z
  .union([z.string(), z.array(z.string())])
  .optional()
  .transform((val) => {
    if (!val) return []
    return Array.isArray(val) ? val : [val]
  })

Coerce string → number

Query params are always strings — z.coerce.number() converts automatically:

limit: z.coerce.number().optional().transform((val) => val ?? 20)
dateFrom: z.coerce.number().optional()

Mutual exclusivity with .refine()

.refine((data) => !(data.mobileNumber && data.userId), {
  message: 'Use either mobileNumber or userId, not both',
  path: ['mobileNumber']
})

Infer TypeScript type from schema

export type GetSessionsQueryParams = z.infer<typeof GetSessionsQuerySchema>

No need to maintain a separate type definition — it stays in sync automatically.