homesponsors ⭐
 
   
🔍

Request validation with Zod in Express

July 21, 2026

Express does not validate request input for you. Without a check at the edge, handlers get raw req.body, req.query, and req.params - strings where you expected numbers, missing fields, and shapes that only blow up deep in business logic.

Zod is a TypeScript-first schema library. You declare the shape once, infer types with z.infer, and parse at the HTTP boundary so route handlers only see valid data. Invalid input becomes HTTP 400 before your code runs.

This post covers Zod 4 schemas (z.email(), z.uuid(), z.coerce), Express validation middleware, error formatting and pitfalls.

Prerequisites

  • Node.js version 26
  • Zod 4: npm i zod
  • Express: npm i express and npm i -D @types/express

Zod 3 method forms like z.string().email() still work but are deprecated in v4. Prefer the top-level APIs below.

Schemas

// schemas.ts
import { z } from 'zod';
export const createUserSchema = z.object({
email: z.email(),
name: z.string().min(1).max(100),
age: z.number().int().min(0).max(150).optional()
});
export type CreateUserInput = z.infer<typeof createUserSchema>;
export const userIdParamSchema = z.object({
id: z.uuid()
});
export const listUsersQuerySchema = z.object({
limit: z.coerce.number().int().min(1).max(100).default(10),
q: z.string().trim().min(1).optional()
});

z.coerce.number() is useful for query strings - HTTP query values arrive as strings. Prefer safeParse over parse at the edge so you control the HTTP status and response body.

Format errors once

Map ZodError.issues into a stable JSON body, or use Zod 4 helpers z.flattenError() / z.treeifyError() when you want field-keyed or nested shapes.

// format-zod-error.ts
import { ZodError } from 'zod';
export function formatZodError(error: ZodError) {
return {
message: 'Validation failed',
issues: error.issues.map((issue) => ({
path: issue.path.join('.') || '(root)',
message: issue.message,
code: issue.code
}))
};
}

Validation middleware

Validate body, query, and params before the route handler. Write parsed data back so handlers receive typed, coerced values.

// validate.ts
import { NextFunction, Request, Response } from 'express';
import { ZodType } from 'zod';
import { formatZodError } from './format-zod-error';
type RequestSchemas = {
body?: ZodType;
query?: ZodType;
params?: ZodType;
};
export function validate(schemas: RequestSchemas) {
return (req: Request, res: Response, next: NextFunction) => {
const parseOrReject = (schema: ZodType, value: unknown) => {
const parsed = schema.safeParse(value);
if (!parsed.success) {
res.status(400).json(formatZodError(parsed.error));
return null;
}
return parsed.data;
};
if (schemas.body) {
const body = parseOrReject(schemas.body, req.body);
if (body === null) return;
req.body = body;
}
if (schemas.query) {
const query = parseOrReject(schemas.query, req.query);
if (query === null) return;
res.locals.query = query;
}
if (schemas.params) {
const params = parseOrReject(schemas.params, req.params);
if (params === null) return;
res.locals.params = params;
}
next();
};
}

Wire it per route:

app.post('/users', validate({ body: createUserSchema }), (req, res) => {
// req.body is CreateUserInput
res.status(201).json({ id: crypto.randomUUID(), ...req.body });
});
app.get('/users', validate({ query: listUsersQuerySchema }), (req, res) => {
const { limit, q } = res.locals.query;
// ...
});
app.get('/users/:id', validate({ params: userIdParamSchema }), (req, res) => {
const { id } = res.locals.params;
// ...
});

Query and params are stored on res.locals because Express types treat req.query / req.params as string maps; replacing them with coerced objects fights the type system.

Pitfalls

  • Query strings are strings - use z.coerce (or z.string() + transform) for numbers and booleans.
  • parse vs safeParse - parse throws a raw ZodError; map it to HTTP 400 yourself or stick to safeParse.
  • Strip unknown keys - Zod object schemas strip unknown keys by default. Use .strict() if you want to reject them.
  • Do not trust types alone - CreateUserInput is compile-time only; always parse at the boundary.
  • Stricter UUIDs in Zod 4 - z.uuid() follows RFC 9562/4122 more strictly; use z.guid() for a looser 8-4-4-4-12 hex pattern.

Request context in Node.js with AsyncLocalStorage

June 28, 2026

In Express and NestJS apps you often need a requestId, userId, or trace fields deep in services and loggers without threading a context object through every function. A module-level global breaks under concurrent requests; passing ctx through every layer is noisy.

AsyncLocalStorage (built into node:async_hooks) gives each async execution chain its own store. Concurrent requests stay isolated, and await, timers, and Promise chains inside a request inherit the same context automatically.

This post covers the API, an Express middleware pattern, a NestJS interceptor with parameter decorators, and common pitfalls.

What AsyncLocalStorage is

AsyncLocalStorage has been stable since Node.js 16.4. No npm package is required.

ApproachConcurrent requestsPropagates through await
Global variableBreaksYes
Pass ctx argumentSafeManual at every layer
AsyncLocalStorageSafeAutomatic

Prerequisites

  • Node.js version 26
  • npm i express

Core API

import { AsyncLocalStorage } from 'node:async_hooks';
const als = new AsyncLocalStorage();
// Start a context for this async chain
als.run({ requestId: 'abc' }, () => {
doWork();
});
// Read the current store anywhere in the chain
const store = als.getStore();
  • als.run(store, callback) - preferred entry point; call once per request in middleware.
  • als.getStore() - returns the store for the current async chain, or undefined outside run().
  • als.enterWith(store) - sets context for the current execution resource; useful in some framework hooks, but run() is the default pattern for Express middleware.

Express middleware pattern

Create a store per request in middleware, then read it from loggers and services without passing arguments.

// context.js
import { AsyncLocalStorage } from 'node:async_hooks';
export const requestContext = new AsyncLocalStorage();
export function getRequestId() {
return requestContext.getStore()?.requestId;
}
// logger.js
import { getRequestId } from './context.js';
export function log(level, message, extra = {}) {
console.log(
JSON.stringify({
level,
requestId: getRequestId(),
message,
...extra,
})
);
}
// middleware.js
import { randomUUID } from 'node:crypto';
import { requestContext } from './context.js';
export function requestContextMiddleware(req, res, next) {
const store = {
requestId: req.headers['x-request-id'] ?? randomUUID(),
};
res.setHeader('x-request-id', store.requestId);
requestContext.run(store, () => next());
}

Wire middleware before routes. A handler can await a simulated database call and log without receiving requestId:

// routes.js
import { log } from './logger.js';
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function findUserById(id) {
await delay(100);
log('info', 'Loaded user from database', { userId: id });
return { id, name: `User ${id}` };
}
export async function getUser(req, res) {
const user = await findUserById(req.params.id);
res.json(user);
}
// app.js
import express from 'express';
import { requestContextMiddleware } from './middleware.js';
import { getUser } from './routes.js';
import { getRequestId } from './context.js';
const app = express();
app.use(requestContextMiddleware);
app.get('/users/:id', getUser);
app.listen(3000, () => {
console.log('Listening on http://localhost:3000');
});

Send concurrent requests with different x-request-id headers. Logs interleave on stdout, but each line carries the correct requestId for its request.

NestJS interceptor and decorator

The flow is the same as Express - an interceptor replaces middleware. For production apps, nestjs-cls wraps AsyncLocalStorage with typed stores and plugins; the snippets below use the built-in API directly.

Prerequisites add-on: npm i @nestjs/common @nestjs/core @nestjs/platform-express reflect-metadata rxjs

Shared store:

// request-context.storage.ts
import { AsyncLocalStorage } from 'node:async_hooks';
export type RequestContextStore = { requestId: string };
export const requestContext = new AsyncLocalStorage<RequestContextStore>();

Wrap each request in als.run() with a global interceptor. Subscribe inside run() so context propagates through async route handlers:

// request-context.interceptor.ts
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { Observable } from 'rxjs';
import { requestContext, RequestContextStore } from './request-context.storage';
@Injectable()
export class RequestContextInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const req = context.switchToHttp().getRequest();
const store: RequestContextStore = {
requestId: (req.headers['x-request-id'] as string) ?? randomUUID(),
};
const res = context.switchToHttp().getResponse();
res.setHeader('x-request-id', store.requestId);
return new Observable((subscriber) => {
requestContext.run(store, () => {
next.handle().subscribe(subscriber);
});
});
}
}

Parameter decorators read from the store in controllers:

// request-context.decorator.ts
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
import { requestContext, RequestContextStore } from './request-context.storage';
export const RequestContext = createParamDecorator(
(_data: unknown, _ctx: ExecutionContext): RequestContextStore | undefined =>
requestContext.getStore(),
);
export const RequestId = createParamDecorator(
(): string | undefined => requestContext.getStore()?.requestId,
);

Register the interceptor globally and use @RequestId() in a controller. Services can call requestContext.getStore() directly (for example from an injectable logger):

// app.module.ts
import { Module } from '@nestjs/common';
import { APP_INTERCEPTOR } from '@nestjs/core';
import { RequestContextInterceptor } from './request-context/request-context.interceptor';
import { UsersModule } from './users/users.module';
@Module({
imports: [UsersModule],
providers: [{ provide: APP_INTERCEPTOR, useClass: RequestContextInterceptor }],
})
export class AppModule {}
// users.controller.ts
@Get(':id')
async getUser(@Param('id') id: string, @RequestId() requestId: string) {
const user = await this.usersService.findById(id);
return { requestId, user };
}

@Transactional() from typeorm-transactional uses the same ALS propagation idea for database transactions. See TypeORM examples with NestJS for a real-world decorator built on top of request-scoped context.

Why it works

Node ties the store to the async resource created when als.run() executes. Any async work started inside that callback - including await, setTimeout, and nested Promise chains - runs in the same context. Each concurrent request calls als.run() with its own store, so getStore() always returns the value for the current chain.

Common pitfalls

  • Call als.run() at the request boundary (middleware), not inside a shared singleton constructor.
  • getStore() returns undefined outside run() - guard with optional chaining or a default.
  • Callbacks scheduled before run() or from another request will not see your store.
  • Worker threads and cluster workers each have separate AsyncLocalStorage instances.