Skip to main content

Validation — shapeguard

validate(), handle(), defineRoute(), createDTO(), schemas, types, pre-parse guards.


defineRoute()

Bundle all schemas for a route into one object. Pass it directly to validate() or handle(). Define once — use in controller, infer types for service and repository.

import { z } from 'zod'
import { defineRoute } from 'shapeguard'

export const CreateUserRoute = defineRoute({
body: CreateUserBodySchema, // what client sends
params: UserParamsSchema, // route segments :id
query: UserQuerySchema, // ?page=1&limit=20
headers: UserHeadersSchema, // Authorization etc
response: UserResponseSchema, // what client receives — strips unknown fields
})

All fields are optional — include only what the route needs:

// GET route — no body
export const GetUserRoute = defineRoute({
params: UserParamsSchema,
response: UserResponseSchema,
})

// LIST route — no body, no params
export const ListUsersRoute = defineRoute({
query: UserQuerySchema,
response: UserListSchema,
})

// DELETE route — user decides response
export const DeleteUserRoute = defineRoute({
params: UserParamsSchema,
})

createDTO()

Removes the z.infer<typeof ...> boilerplate from every schema file. Under the hood it is z.object() — same runtime behaviour, same Zod methods.

Before (v0.1.x)

const CreateUserBodySchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
password: z.string().min(8),
})

export type CreateUserBody = z.infer<typeof CreateUserBodySchema> // ← repeated every time

With createDTO()

import { createDTO } from 'shapeguard'

export const CreateUserDTO = createDTO(z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
password: z.string().min(8),
}))

export type CreateUserBody = typeof CreateUserDTO.Input // ← inferred automatically

Pass directly to defineRoute() — it is a valid Zod schema:

export const CreateUserRoute = defineRoute({
body: CreateUserDTO,
response: UserResponseDTO,
})

validate()

Attach to any route as middleware. Validates before your handler runs.

// pass full route definition
validate(CreateUserRoute)

// or pick individual pieces
validate({
body: CreateUserBodySchema,
sends: UserResponseSchema,
})

// return ALL validation errors instead of just the first
validate({
body: CreateUserBodySchema,
allErrors: true,
})

// override pre-parse limits for this specific route
validate({
body: FileUploadBodySchema,
limits: { maxStringLength: 100_000 },
})

// control what error info is exposed on this route
validate({
body: CreateUserBodySchema,
sanitize: { exposeEnumValues: true },
})

After validate() runs:

  • req.body is typed from your schema
  • req.params is typed from your schema
  • req.query is typed from your schema
  • Unknown fields are stripped silently
  • Invalid fields return 422 immediately — handler never runs
export const createUser = [
validate(CreateUserRoute),
asyncHandler(async (req, res) => {
req.body.email // string — typed ✅
req.body.isAdmin // TypeScript error — not in schema ✅
})
]

handle()

Combines validate() + asyncHandler() into a single call. This is the recommended pattern.

Before

import { validate, asyncHandler } from 'shapeguard'

export const createUser = [
validate(CreateUserRoute),
asyncHandler(async (req, res) => {
const user = await UserService.create(req.body)
res.created({ data: user, message: 'User created' })
})
]

With handle()

import { handle } from 'shapeguard'

export const createUser = handle(CreateUserRoute, async (req, res) => {
const user = await UserService.create(req.body)
res.created({ data: user, message: 'User created' })
})

handle() is compatible with createRouter() — spread it the same way:

router.post('/',   ...createUser)
router.get('/:id', ...getUser)

Both validate() + asyncHandler() and handle() work side by side — migrate one route at a time.


Transform hook

Run logic after validation and before your handler — hash passwords, normalise fields, enrich data. Keeps your service layer pure.

Define transform on defineRoute():

export const CreateUserRoute = defineRoute({
body: CreateUserBodySchema,
response: UserResponseSchema,
transform: async (data) => ({
...data,
password: await bcrypt.hash(data.password, 10),
}),
})

The flow is: validate → transform → handler. Your handler receives already-transformed data:

export const createUser = handle(CreateUserRoute, async (req, res) => {
// req.body.password is already hashed here — service stays clean
const user = await UserService.create(req.body)
res.created({ data: user, message: 'User created' })
})

Transform also works with validate() if you have not migrated to handle() yet:

export const createUser = [
validate(CreateUserRoute), // transform runs here
asyncHandler(async (req, res) => { ... })
]

What gets validated

body — JSON payload

const CreateUserBodySchema = z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
password: z.string().min(8),
role: z.enum(['admin', 'member', 'viewer']),
})

// extra fields stripped silently
{ "email": "alice@example.com", "isAdmin": true }
// → isAdmin gone before handler runs

params — route segments

const UserParamsSchema = z.object({
id: z.string().uuid(),
})

// GET /api/users/550e8400-e29b-41d4-a716-446655440000
req.params.id // typed uuid string ✅

// GET /api/users/not-a-uuid
// → 422 VALIDATION_ERROR before handler runs

query — URL parameters

// query params are always strings from the URL — use z.coerce to convert
const UserQuerySchema = z.object({
page: z.coerce.number().min(1).default(1),
limit: z.coerce.number().min(1).max(100).default(20),
role: z.enum(['admin', 'member', 'viewer']).optional(),
search: z.string().max(100).optional(),
})

// GET /api/users?page=2&limit=10
req.query.page // 2 — number, not string "2" ✅
req.query.limit // 10 ✅

headers — request headers

// Node.js lowercases all header names
const UserHeadersSchema = z.object({
authorization: z.string().startsWith('Bearer '),
'x-tenant-id': z.string().uuid().optional(),
})

req.headers.authorization // "Bearer eyJ..." — typed and validated ✅

response — strips outgoing fields

const UserResponseSchema = z.object({
id: z.string().uuid(),
email: z.string(),
name: z.string(),
createdAt: z.string().datetime(),
// passwordHash NOT here → stripped automatically
// stripeCustomerId NOT here → stripped automatically
})

// DB returns { id, email, name, createdAt, passwordHash, stripeCustomerId }
// Client receives { id, email, name, createdAt }
// sensitive fields gone — even if you forget about them

Schema naming convention

// Zod schemas    → PascalCase + Schema suffix
const UserResponseSchema = z.object({ ... })
const CreateUserBodySchema = z.object({ ... })
const UpdateUserBodySchema = z.object({ ... })
const UserParamsSchema = z.object({ ... })
const UserQuerySchema = z.object({ ... })

// DTOs → PascalCase + DTO suffix
const CreateUserDTO = createDTO({ ... })

// Route bundles → PascalCase + Route suffix
export const CreateUserRoute = defineRoute({ ... })
export const GetUserRoute = defineRoute({ ... })

// Inferred types → PascalCase, no suffix
export type UserResponse = z.infer<typeof UserResponseSchema>
export type CreateUserBody = z.infer<typeof CreateUserBodySchema>
// or with createDTO:
export type CreateUserBody = CreateUserDTO.Input

Why separate names for schema and type:

// ❌ BREAKS — const and type cannot share the same name
const CreateUserBody = z.object({ ... })
export type CreateUserBody = z.infer<typeof CreateUserBody> // error

// ✅ WORKS — Schema suffix on const, clean name on type
const CreateUserBodySchema = z.object({ ... })
export type CreateUserBody = z.infer<typeof CreateUserBodySchema>

Full CRUD example

// validators/user.validator.ts
import { z } from 'zod'
import { defineRoute, createDTO } from 'shapeguard'

// ── params ────────────────────────────────────
const UserParamsSchema = z.object({
id: z.string().uuid(),
})

// ── query ─────────────────────────────────────
const UserQuerySchema = z.object({
page: z.coerce.number().min(1).default(1),
limit: z.coerce.number().min(1).max(100).default(20),
role: z.enum(['admin', 'member', 'viewer']).optional(),
search: z.string().max(100).optional(),
})

// ── DTOs ──────────────────────────────────────
export const CreateUserDTO = createDTO(z.object({
email: z.string().email(),
name: z.string().min(1).max(100),
password: z.string().min(8),
role: z.enum(['admin', 'member', 'viewer']),
}))

export const UpdateUserDTO = createDTO(z.object({
name: z.string().min(1).max(100).optional(),
role: z.enum(['admin', 'member', 'viewer']).optional(),
password: z.string().min(8).optional(),
}))

// ── response ──────────────────────────────────
const UserResponseSchema = z.object({
id: z.string().uuid(),
email: z.string(),
name: z.string(),
role: z.enum(['admin', 'member', 'viewer']),
createdAt: z.string().datetime(),
updatedAt: z.string().datetime(),
})

// ── routes ────────────────────────────────────
export const CreateUserRoute = defineRoute({
body: CreateUserDTO,
response: UserResponseSchema,
transform: async (data) => ({
...data,
password: await bcrypt.hash(data.password, 10),
}),
})

export const GetUserRoute = defineRoute({
params: UserParamsSchema,
response: UserResponseSchema,
})

export const UpdateUserRoute = defineRoute({
params: UserParamsSchema,
body: UpdateUserDTO,
response: UserResponseSchema,
})

export const DeleteUserRoute = defineRoute({
params: UserParamsSchema,
})

export const ListUsersRoute = defineRoute({
query: UserQuerySchema,
response: z.object({ users: z.array(UserResponseSchema) }),
})

// ── inferred types ────────────────────────────
export type UserParams = z.infer<typeof UserParamsSchema>
export type UserQuery = z.infer<typeof UserQuerySchema>
export type CreateUserBody = CreateUserDTO.Input
export type UpdateUserBody = UpdateUserDTO.Input
export type UserResponse = z.infer<typeof UserResponseSchema>

Pre-parse guards

These run on every request before Zod ever sees the data. Proto pollution and unicode sanitization cannot be disabled. Size/depth limits have defaults but are configurable per-route or globally.

GuardDefaultStatusWhat it blocks
Proto pollutionalways400__proto__, constructor, prototype stripped
Unicode sanitizealways400null bytes, zero-width chars, RTL override stripped
Object depth20 levels400billion-laughs attacks, stack overflows
Array length1000 items400memory exhaustion
String length10,000 chars400memory exhaustion
Content-Typealways415POST/PUT/PATCH without Content-Type rejected

Configure limits globally in shapeguard():

app.use(shapeguard({
validation: {
limits: {
maxDepth: 20,
maxArrayLength: 1000,
maxStringLength: 10_000,
}
}
}))

Or override per-route in validate() or handle():

// larger limit for this route
validate({ body: FileBodySchema, limits: { maxStringLength: 500_000 } })

// handle() supports the same options
handle({ ...FileUploadRoute, limits: { maxStringLength: 500_000 } }, async (req, res) => { ... })

Edge cases

Empty body on POST          → 422 VALIDATION_ERROR — "body is required"
Body is array not object → 422 — "Expected object, received array"
Extra unknown fields → stripped silently, never an error
__proto__ in body → stripped at JSON.parse time
Deeply nested object → rejected at depth limit before Zod runs
?role=a&role=b (pollution) → 400 PARAM_POLLUTION — repeated query params are rejected before Zod runs
Query page=abc → 422 — "Expected number" (use z.coerce.number())
Missing required param → Express won't match route — never reaches validate()
transform throws → caught, passed to errorHandler as AppError.internal()

PARAM_POLLUTION: Express parses ?role=admin&role=user as role: ['admin','user'] — an array where your schema expects a string. shapeguard detects this before Zod runs and returns 400 PARAM_POLLUTION. This prevents attackers from injecting unexpected arrays into scalar fields.


Adapters — Joi and Yup

Zod is first class. Joi and Yup work via adapters — install the library and import the adapter.

Joi

import { joiAdapter } from 'shapeguard/adapters/joi'
import Joi from 'joi'

const CreateUserSchema = Joi.object({
email: Joi.string().email().required(),
name: Joi.string().min(1).max(100).required(),
password: Joi.string().min(8).required(),
})

// pass directly to validate()
validate({ body: joiAdapter(CreateUserSchema) })

// or wrap in defineRoute() then use with handle()
export const CreateUserRoute = defineRoute({
body: joiAdapter(CreateUserSchema),
response: joiAdapter(UserResponseSchema),
})

export const createUser = handle(CreateUserRoute, async (req, res) => {
const user = await UserService.create(req.body)
res.created({ data: user })
})

Yup

import { yupAdapter } from 'shapeguard/adapters/yup'
import * as yup from 'yup'

const CreateUserSchema = yup.object({
email: yup.string().email().required(),
name: yup.string().min(1).max(100).required(),
password: yup.string().min(8).required(),
})

validate({ body: yupAdapter(CreateUserSchema) })

Note: createDTO() is Zod-only. For Joi/Yup pass the adapter directly to defineRoute() or validate().


Per-route rate limiting — rateLimit

No extra package needed

Add rateLimit to any defineRoute() call:

import { defineRoute, createDTO } from 'shapeguard'
import { z } from 'zod'

const CreateUserRoute = defineRoute({
body: createDTO(z.object({ email: z.string().email() })),
rateLimit: {
windowMs: 60_000, // 1 minute window
max: 10, // 10 requests per IP per window
message: 'Too many registrations — try again in a minute',

// Plug in Redis for multi-instance production
store: redisStore,

// Key by user ID instead of IP (e.g. authenticated routes)
keyGenerator: (req) => req.user?.id ?? req.ip,
}
})

When the limit is exceeded shapeguard responds with 429 and ErrorCode.RATE_LIMIT_EXCEEDED:

{
"success": false,
"message": "Too many registrations — try again in a minute",
"error": { "code": "RATE_LIMIT_EXCEEDED", "details": { "retryAfter": 35 } }
}

The retryAfter field tells the client how many seconds until the window resets.


Per-route cache hints — cache

Sets Cache-Control response header automatically

// Public endpoint — CDN + browser cache for 60s
const GetProductRoute = defineRoute({
params: z.object({ id: z.string().uuid() }),
response: ProductSchema,
cache: { maxAge: 60 }, // Cache-Control: public, max-age=60
})

// User-specific — browser cache only
const GetProfileRoute = defineRoute({
response: ProfileSchema,
cache: { maxAge: 300, private: true }, // Cache-Control: private, max-age=300
})

// Sensitive — never cache
const GetPaymentRoute = defineRoute({
cache: { maxAge: 0, noStore: true }, // Cache-Control: no-store
})

Cache headers are set before your handler runs — so even if the handler throws, the header is already set correctly.