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>;
import { Request, Response, NextFunction, RequestHandler } from "express";
import { ZodTypeAny } from "zod";
export function validateQuery(schema: ZodTypeAny): RequestHandler {
return (req: Request, res: Response, next: NextFunction) => {
const result = schema.safeParse(req.query);
if (!result.success) {
return res.status(400).json({
error: "Invalid query parameters",
details: result.error.flatten(),
});
}
res.locals.query = result.data;
next();
};
}
import { Router } from "express";
import { validateQuery } from "./validateQuery";
import { listQuerySchema, ListQuery } from "./queryCoercion";
import { productRepo } from "./productRepo";
export const products = Router();
products.get("/products", validateQuery(listQuerySchema), async (_req, res) => {
const { page, limit, active, tags } = res.locals.query as ListQuery;
const rows = await productRepo.list({
offset: (page - 1) * limit,
limit,
active,
tags,
});
res.json({ page, limit, count: rows.length, data: rows });
});
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
payload = {
sub: user.id,
iss: 'https://auth.example.com',
aud: 'codesnips-api',
exp: 15.minutes.from_now.to_i,
iat: Time.now.to_i,
JWT issuance and verification without common footguns
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
type User {
id: ID!
name: String!
email: String!
posts: [Post!]!
createdAt: String!
GraphQL API with Spring Boot
class SignupForm
include ActiveModel::Model
include ActiveModel::Attributes
attribute :account_name, :string
attribute :email, :string
Shallow Controller, Deep Params: Form Object Pattern
import axios, { AxiosError } from 'axios'
import { v4 as uuidv4 } from 'uuid'
const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || 'http://localhost:3000/api/v1',
timeout: 15000,
Axios API client with interceptors
Share this code
Here's the card — post it anywhere.