typescript 68 lines · 3 tabs

API input coercion for query params (Zod preprocess)

Shared by codesnips Jan 2026
3 tabs
import { z } from "zod";

const coerceNumber = z.preprocess((v) => {
  if (v === "" || v === undefined) return undefined;
  if (typeof v !== "string") return v;
  const n = Number(v);
  return Number.isNaN(n) ? v : n; // pass bad strings through for a real error
}, z.number().int());

const coerceBoolean = z.preprocess((v) => {
  if (typeof v !== "string") return v;
  const t = v.trim().toLowerCase();
  if (["true", "1", "yes"].includes(t)) return true;
  if (["false", "0", "no"].includes(t)) return false;
  return v;
}, z.boolean());

const coerceCsv = z.preprocess((v) => {
  if (Array.isArray(v)) return v;
  if (typeof v === "string") {
    return v.split(",").map((s) => s.trim()).filter(Boolean);
  }
  return v;
}, z.array(z.string()));

export const listQuerySchema = z.object({
  page: coerceNumber.default(1).pipe(z.number().min(1)),
  limit: coerceNumber.default(20).pipe(z.number().min(1).max(100)),
  active: coerceBoolean.optional(),
  tags: coerceCsv.default([]),
});

export type ListQuery = z.infer<typeof listQuerySchema>;
3 files · typescript Explain with highlit

HTTP query strings are always strings. A request like ?page=2&active=true&tags=a,b arrives with every value as text (or as an array when a key repeats), which means a schema that naively expects z.number() will always reject it. queryCoercion schemas shows the core technique: z.preprocess runs a transform before the inner schema validates, giving each field a chance to convert the raw string into the shape the type actually wants.

In queryCoercion schemas, coerceNumber wraps a z.number().int() in z.preprocess, but it is careful about edge cases: an empty string collapses to undefined so it can be .optional() rather than failing as NaN, and a non-numeric string is passed through untouched so the inner schema produces a proper validation error instead of silently becoming NaN. coerceBoolean maps the conventional truthy/falsy tokens (true/1/yes) and leaves anything else alone. coerceCsv handles the two ways a list can appear on the wire — a repeated key (already an array) or a single comma-delimited string — normalizing both into string[].

These helpers compose into listQuerySchema, an ordinary object schema with defaults and bounds (page and limit via coerceNumber, active via coerceBoolean, tags via coerceCsv). Because the coercion lives inside the schema, the inferred type ListQuery is fully typed as numbers, booleans, and arrays with no manual casting downstream.

validateQuery middleware turns any schema into reusable Express middleware. It runs schema.safeParse against req.query, and on failure responds 400 with error.flatten() so clients get field-level messages. On success it assigns the parsed, coerced result to res.locals.query, avoiding mutation of the read-only req.query and keeping the typed value in a predictable place.

products route wires it together: the route declares validateQuery(listQuerySchema) and the handler reads res.locals.query with a cast to ListQuery, so page is a real number and active a real boolean. This pattern centralizes the messy string-to-type conversion at the boundary, keeps handlers clean and type-safe, and produces consistent validation errors — the main trade-off being that preprocess transforms run before validation, so each helper must defend against malformed input rather than assuming it.


Related snips

Share this code

Here's the card — post it anywhere.

API input coercion for query params (Zod preprocess) — share card
Link copied