Skip to main content

Validating Query Params and Env Vars with Zod

This guide is part of Runtime Validation with Zod. It solves a specific failure mode: plain z.number() and z.boolean() schemas reject query string values and environment variables even when the incoming data is semantically correct, because those sources always deliver strings.

Symptom

A route handler that worked fine in unit tests starts failing in integration:

ZodError: [
  {
    "code": "invalid_type",
    "expected": "number",
    "received": "string",
    "path": ["page"],
    "message": "Expected number, received string"
  },
  {
    "code": "invalid_type",
    "expected": "boolean",
    "received": "string",
    "path": ["includeArchived"],
    "message": "Expected boolean, received string"
  }
]

The request URL was GET /items?page=2&includeArchived=true. The data is perfectly valid, but Zod reports type mismatches on both fields. A parallel failure surfaces at startup when an env schema validates process.env:

ZodError: [
  {
    "code": "invalid_type",
    "expected": "number",
    "received": "string",
    "path": ["PORT"],
    "message": "Expected number, received string"
  },
  {
    "code": "invalid_type",
    "expected": "string",
    "received": "undefined",
    "path": ["DATABASE_URL"],
    "message": "Required"
  }
]

process.env.PORT is "3000" — a string — not the number 3000. And process.env.DATABASE_URL returns undefined when the key is absent (TypeScript types it as string | undefined for exactly this reason), which a z.string() field without .optional() rejects.

Root Cause

HTTP query strings and operating-system environment variables share the same constraint: every value is a string. The URL spec encodes all query data as text. Node.js surfaces process.env as Record<string, string | undefined> — the optional undefined covers variables that are simply not set.

Zod’s primitive validators are strict by design. z.number() matches only the JavaScript number primitive. z.boolean() matches only true or false. Neither performs implicit coercion. When req.query.page is "2" (a string), z.number() sees the wrong primitive type and fails immediately.

This is not a Zod bug. The validation is correct. The schema is the wrong tool for the source. The fix is to introduce a coercion step before validation runs, or to use Zod’s built-in coerce namespace that does exactly that.

Step-by-Step Fix

Step 1: Replace Primitive Types with z.coerce for Query Schemas

z.coerce.number() wraps the native Number() constructor around the raw input before running the numeric validator. z.coerce.boolean() calls Boolean(). For numbers this is exactly what you want: Number("2") returns 2. For booleans read carefully — the edge cases are in Step 2.

// Zod 3.23 — query-schema.ts
import { z } from 'zod';

// BEFORE: rejects strings from URL query
const BrokenListQuery = z.object({
  page: z.number().int().positive().default(1),       // ❌ "2" → invalid_type
  limit: z.number().int().min(1).max(100).default(20),// ❌ "20" → invalid_type
  includeArchived: z.boolean().default(false),         // ❌ "true" → invalid_type
});

// AFTER: coerce converts strings before validation
export const ListQuerySchema = z.object({
  page: z.coerce.number().int().positive().default(1),
  //    ^^^^^^^^ calls Number(raw) then validates the result
  limit: z.coerce.number().int().min(1).max(100).default(20),
  includeArchived: z.coerce.boolean().default(false),
  // ⚠️  z.coerce.boolean() calls Boolean(raw). See Step 2 for why
  //     this is wrong for the string "false".
});

export type ListQuery = z.infer<typeof ListQuerySchema>;
// { page: number; limit: number; includeArchived: boolean }
// Note: z.infer gives the OUTPUT type (post-coerce). The input is strings.

Wire the schema into your Express route. Keep the schema at module scope — recreating it inside the handler is unnecessary work per request:

import type { Request, Response } from 'express';
import { ListQuerySchema } from './query-schema';

export async function listItemsHandler(req: Request, res: Response) {
  const result = ListQuerySchema.safeParse(req.query);
  // req.query is Record<string, string | ParsedQs | ...>
  // safeParse never throws; check result.success before using result.data
  if (!result.success) {
    return res.status(400).json({
      code: 'INVALID_QUERY',
      details: result.error.flatten().fieldErrors,
    });
  }

  const { page, limit, includeArchived } = result.data;
  // page: number, limit: number, includeArchived: boolean — fully typed
  const offset = (page - 1) * limit;
  // ... pass to your data layer
}

Why this works. z.coerce.number() is syntactic sugar for z.preprocess((v) => Number(v), z.number()). Zod runs the coercion step first, so by the time the z.number() validator runs it receives a real number primitive. Chain .int(), .positive(), .min(), .max() exactly as you would on a plain z.number() — they operate on the coerced value.

Step 2: Use z.preprocess for Booleans and Multi-Value Arrays

z.coerce.boolean() calls Boolean(raw). Boolean("false") is true because "false" is a non-empty string. This is almost certainly not the behavior you want when a URL carries ?includeArchived=false.

Use z.preprocess with an explicit mapping function to correctly handle the four conventional boolean-string representations:

import { z } from 'zod';

// z.preprocess(fn, schema) — fn runs first, schema validates the result.
const booleanFromString = z.preprocess((raw) => {
  if (raw === 'true' || raw === '1') return true;
  if (raw === 'false' || raw === '0') return false;
  return raw; // pass through so z.boolean() can reject it cleanly
}, z.boolean());

// Multi-value array: ?tags=api&tags=rest  →  ["api", "rest"]
// Express sets req.query.tags to either a string or string[] depending
// on whether the key appears once or multiple times.
const stringArrayFromQuery = z.preprocess((raw) => {
  if (Array.isArray(raw)) return raw;
  if (typeof raw === 'string') return [raw]; // single value → wrap
  return [];  // absent key → empty array
}, z.array(z.string().min(1)));

export const FilterQuerySchema = z.object({
  page: z.coerce.number().int().positive().default(1),
  limit: z.coerce.number().int().min(1).max(100).default(20),
  includeArchived: booleanFromString.default(false),
  tags: stringArrayFromQuery.default([]),
  sortBy: z.enum(['createdAt', 'updatedAt', 'name']).default('createdAt'),
  order: z.enum(['asc', 'desc']).default('asc'),
});

export type FilterQuery = z.infer<typeof FilterQuerySchema>;

The schema pairs naturally with the cursor and offset patterns in Pagination and Filtering Schema Patterns, which documents how page + limit should map to SQL OFFSET and how sortBy + order should be validated before reaching the query builder.

Why this works. z.preprocess gives you a raw escape hatch: your function receives the original input value before any Zod logic runs, applies exactly the transformation you specify, and hands the result to the inner schema. This is the correct tool when the conversion rule is not a simple constructor call.

Step 3: Write a Typed Env Schema and Parse It at Boot

Environment variable validation has one hard rule: validate everything once, at process startup, and exit immediately if anything is wrong. A process that starts with a missing DATABASE_URL will fail on the first query with a cryptic connection error instead of a clear configuration message.

// Zod 3.23 — env.ts
import { z } from 'zod';

// Cover every variable the application reads.
// Group by concern so the schema doubles as documentation.
const EnvSchema = z.object({

  // ── Runtime ────────────────────────────────────────────────────────────────
  NODE_ENV: z.enum(['development', 'test', 'production']),
  PORT: z.coerce.number().int().min(1).max(65535).default(3000),
  //    process.env.PORT is always a string; coerce converts it.

  // ── Database ───────────────────────────────────────────────────────────────
  DATABASE_URL: z.string().url(),
  // No .optional() — this must be present. The error will name the variable.
  DATABASE_POOL_MIN: z.coerce.number().int().min(1).default(2),
  DATABASE_POOL_MAX: z.coerce.number().int().min(1).default(10),

  // ── Auth ───────────────────────────────────────────────────────────────────
  JWT_SECRET: z.string().min(32), // enforce minimum entropy
  JWT_EXPIRES_IN: z.string().default('7d'),

  // ── Feature flags ─────────────────────────────────────────────────────────
  ENABLE_RATE_LIMITING: z.preprocess((v) => {
    if (v === 'true' || v === '1') return true;
    if (v === 'false' || v === '0') return false;
    return v;
  }, z.boolean()).default(true),

  // ── Optional external services ────────────────────────────────────────────
  REDIS_URL: z.string().url().optional(),
  // .optional() means the variable may be absent entirely.
  // Process code must handle env.REDIS_URL being undefined.
});

// Parse ONCE. Use safeParse so ALL errors surface together, not just the first.
const parsed = EnvSchema.safeParse(process.env);

if (!parsed.success) {
  // flatten() gives { fieldErrors: { PORT: [...], DATABASE_URL: [...] } }
  // Log every misconfigured variable before exiting.
  console.error('❌  Invalid environment configuration:');
  console.error(JSON.stringify(parsed.error.flatten().fieldErrors, null, 2));
  process.exit(1); // never start a misconfigured process
}

// Export the typed, validated env object.
// Everywhere else in the codebase, import this instead of reading process.env.
export const env = parsed.data;
// TypeScript now knows env.PORT is number, env.DATABASE_URL is string, etc.

Import env from this module wherever configuration is consumed:

// db.ts
import { env } from './env';
import { Pool } from 'pg';

// No string-to-number cast needed — env.DATABASE_POOL_MAX is already number.
export const pool = new Pool({
  connectionString: env.DATABASE_URL,
  min: env.DATABASE_POOL_MIN,
  max: env.DATABASE_POOL_MAX,
});

Why this works. Centralising process.env access in one validated module means every downstream consumer works with correct types. TypeScript sees env.PORT as number, not string | undefined, so accidental arithmetic on a string is a compile-time error. The fail-fast process.exit(1) ensures that a container or serverless function either starts correctly configured or not at all — the absence of partial-start failures simplifies on-call investigation dramatically.

Before / After Comparison

// ── BEFORE ──────────────────────────────────────────────────────────────────

// Query schema: rejects real query strings
const QuerySchema = z.object({
  page: z.number(),         // ❌ "2"      → invalid_type: expected number
  active: z.boolean(),      // ❌ "true"   → invalid_type: expected boolean
  tags: z.array(z.string()) // ❌ "api"    → invalid_type: expected array
});

// Env schema: rejects process.env values
const Env = z.object({
  PORT: z.number(),         // ❌ "3000"   → invalid_type: expected number
  FLAG: z.boolean(),        // ❌ "false"  → invalid_type: expected boolean (truthy surprise if coerced)
  DB: z.string(),           // ❌ undefined → Required
});

// ── AFTER ───────────────────────────────────────────────────────────────────

// Query schema: coerce handles string→primitive conversion
const QuerySchema = z.object({
  page: z.coerce.number(),          // ✅ "2"     → 2
  active: booleanFromString,        // ✅ "true"  → true, "false" → false
  tags: stringArrayFromQuery,       // ✅ "api"   → ["api"]
});

// Env schema: validated once at boot with full error reporting
const EnvSchema = z.object({
  PORT: z.coerce.number().default(3000),  // ✅ "3000"   → 3000
  FLAG: booleanEnvField.default(true),    // ✅ "false"  → false (not true!)
  DB: z.string().url(),                   // ✅ missing  → named error at startup
});

Verification

Run the schema against representative inputs in a Vitest test file before wiring it to a route:

// query-schema.test.ts — Vitest
import { describe, it, expect } from 'vitest';
import { FilterQuerySchema } from './query-schema';

describe('FilterQuerySchema', () => {
  it('coerces string numbers', () => {
    const r = FilterQuerySchema.safeParse({ page: '3', limit: '50' });
    expect(r.success).toBe(true);
    expect(r.data?.page).toBe(3);       // number, not "3"
    expect(r.data?.limit).toBe(50);
  });

  it('maps "false" to false, not true', () => {
    const r = FilterQuerySchema.safeParse({ includeArchived: 'false' });
    expect(r.success).toBe(true);
    expect(r.data?.includeArchived).toBe(false); // not true!
  });

  it('wraps a single tag string into an array', () => {
    const r = FilterQuerySchema.safeParse({ tags: 'api' });
    expect(r.success).toBe(true);
    expect(r.data?.tags).toEqual(['api']);
  });

  it('accepts a tags array as-is', () => {
    const r = FilterQuerySchema.safeParse({ tags: ['api', 'rest'] });
    expect(r.success).toBe(true);
    expect(r.data?.tags).toEqual(['api', 'rest']);
  });

  it('applies defaults when fields are absent', () => {
    const r = FilterQuerySchema.safeParse({});
    expect(r.success).toBe(true);
    expect(r.data?.page).toBe(1);
    expect(r.data?.includeArchived).toBe(false);
  });

  it('rejects page=0 (not positive)', () => {
    const r = FilterQuerySchema.safeParse({ page: '0' });
    expect(r.success).toBe(false);
    expect(r.error?.flatten().fieldErrors.page).toBeDefined();
  });
});
$ npx vitest run query-schema.test.ts

 ✓ FilterQuerySchema > coerces string numbers
 ✓ FilterQuerySchema > maps "false" to false, not true
 ✓ FilterQuerySchema > wraps a single tag string into an array
 ✓ FilterQuerySchema > accepts a tags array as-is
 ✓ FilterQuerySchema > applies defaults when fields are absent
 ✓ FilterQuerySchema > rejects page=0 (not positive)

 Test Files  1 passed (1)
      Tests  6 passed (6)

For the env schema, test it in isolation by temporarily overriding process.env fields and calling EnvSchema.safeParse(mockEnv). Keep the env module’s side-effectful process.exit path out of the test runner by extracting the parse step into a pure function that tests call directly, and keeping the exit logic in the entry point.

Query param and env var validation flow Two input sources — URL query string and process.env — both carry string values. Each passes through a coerce or preprocess step that converts strings to the target primitive type, then through a Zod schema validator that produces typed, validated output or a structured error. URL Query ?page=2&active=true all strings process.env PORT="3000" string | undefined z.coerce / z.preprocess string → number/bool/array z.coerce / z.preprocess string → number/bool z.object safeParse() validates primitives Typed data result.data { page: number } 400 / exit(1) result.error fieldErrors map success failure

Edge Cases and Caveats

Empty strings. z.coerce.number() converts an empty string to 0 via Number(""). If your route should reject ?page= as invalid rather than treating it as page 0, add .min(1) or a .refine() check:

page: z.coerce.number().int().positive(),
// .positive() rejects 0, so Number("") → 0 → rejected. Safe.

// Alternatively, explicitly reject empty strings before coercion:
page: z.preprocess((v) => (v === '' ? undefined : v), z.coerce.number().int().positive().default(1)),
// undefined triggers the .default(1) path.

z.coerce.boolean() and truthy coercion. As noted above, Boolean("false") is true. Any non-empty string is truthy. This is the most common trap when switching from z.boolean() to z.coerce.boolean(). The booleanFromString z.preprocess pattern in Step 2 is the correct fix. The fix also affects env var boolean flags like ENABLE_FEATURE=false — without explicit string comparison, z.coerce.boolean() will enable the feature unconditionally.

Array parameters with a single value. Frameworks such as Express and Fastify normalise repeated query keys into arrays, but when a key appears only once, they often leave it as a plain string. The stringArrayFromQuery preprocess wrapper in Step 2 handles this. If you use the qs library (common in Express projects), it has its own arrayFormat setting that can affect whether you receive string or string[] — test against the exact framework behaviour in your environment. For full filter schema design covering sort, range, and enum filters alongside arrays, see Pagination and Filtering Schema Patterns.

Union types and z.preprocess ordering. When a field could be a number or an enum string, place the z.preprocess narrowing first and keep the inner schema as the tightest type that matches the coerced result. If you find yourself reaching for z.union() after z.preprocess, consider whether splitting the field into two distinct parameters would make the schema — and the OpenAPI documentation — clearer. Related structural concerns around union discrimination are covered in Fixing Zod Discriminated Union Mismatches.

Frequently Asked Questions

Why does z.number() reject a query param that is clearly a number?

Query parameters arrive from the URL as strings. z.number() matches only the JavaScript number primitive. Use z.coerce.number() to call Number() on the incoming string before Zod validates the result.

What is the difference between z.coerce and z.preprocess?

z.coerce wraps the field’s native constructor (Number, Boolean, String) around the raw input before validation. z.preprocess accepts an arbitrary transformation function and runs it first, giving you full control over the conversion logic before the schema runs.

How do I validate an array query parameter like ?tags=a&tags=b?

Express and most frameworks collect repeated keys into a string array. Wrap with z.preprocess that normalises a string to [string] when only one value is sent, then apply z.array(z.string()) to the result.

Should I call EnvSchema.parse or safeParse at startup?

Use safeParse, log the flattened fieldErrors so every misconfigured variable is visible at once, then call process.exit(1). Using parse gives you only the first failing field because ZodError is thrown on the first issue.

Does z.coerce.boolean() treat the string ‘false’ as false?

No. z.coerce.boolean() calls Boolean('false'), which is true because the string is non-empty. Use z.preprocess with explicit string comparison to handle 'true'/'false'/'1'/'0' correctly.

When should I re-validate env vars in tests?

Always. Point the test suite at a dedicated .env.test file and run EnvSchema.parse(process.env) in the test setup file. This surfaces missing test-environment variables before any test executes, not mid-suite.