Welcome to the first lesson of Request Validation and Express Error Architecture! Your ticket API already supports filtering, creation, partial updates, deletion, and nested comments backed by PostgreSQL. Now you’ll replace repeated route-level checks with one reusable validation pipeline built with Zod.
You’ll define schemas for route parameters, query strings, ticket bodies, and comment bodies. Then you’ll connect those schemas to Express middleware, store normalized values in response.locals, and let one centralized error handler convert validation failures into consistent 400 VALIDATION_ERROR responses.
Previously, each route manually inspected values such as request.query.status, request.body.title, and request.params.ticketId. Those checks worked, but they repeated parsing, trimming, enum validation, defaults, and error responses across several handlers. Zod moves those rules into reusable contracts so route handlers can focus on application behavior rather than raw input cleanup.
Understanding the Validation Flow
The completed request flow looks like this:
text
API Simulator ↓Express request ↓validateRequest(...) ↓Zod schema parsing and normalization ↓response.locals.validated ↓Route handler ↓Repository and PostgreSQL
When validation fails, the request takes a different path:
The important boundary is that repositories receive only values that have already passed structural validation. PostgreSQL existence checks still remain necessary for references such as requesterId and authorId.
Using the API Simulator as a Postman-Style Client
The API Simulator acts like a lightweight Postman-style HTTP client inside the course environment. It runs in the preview interface on port 3000, while the Express backend continues to listen on port 4000.
Set the base URL to:
text
http://localhost:4000
You can then select a method, enter an endpoint, add query parameters, and send JSON bodies. For example:
text
GET /api/tickets?status=open&priority=high&sortBy=createdAt&order=desc
For request bodies, open Body, choose JSON, paste the payload, and use Beautify when you want the simulator to format inline JSON.
Validation responses are especially useful in the simulator because you can inspect both the HTTP status and the field-specific details returned by the centralized error handler.
Join the 1M+ learners on CodeSignal
Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal
Reusing Domain Values in Zod
The schemas live in src/schemas/ticket-schemas.ts. They import the existing status and priority constants instead of duplicating the allowed strings.
import { z } from "zod";import { ticketPriorities, ticketStatuses,} from "../domain/ticket.js";
ticketStatuses already contains "open", "in_progress", and "closed". Reusing it ensures the domain model and request validation accept the same values.
ticketPriorities provides "low", "medium", and "high". If the domain changes later, the schema can continue deriving its enum from the shared source.
Zod performs runtime validation, while the TypeScript types in the domain file describe compile-time expectations. Together, they protect both external requests and internal code.
Validating Ticket Route Parameters
Ticket detail and nested comment routes depend on ticketId. The schema accepts both seeded identifiers such as tkt_1001 and generated identifiers such as tkt_<uuid>.
export const ticketIdParamsSchema = z.object({ ticketId: z .string() .regex( /^tkt_[A-Za-z0-9-]+$/, "ticketId must use the tkt_<id> format", ),});
The outer z.object() matches the shape of request.params. Express provides route parameters as an object such as { ticketId: "tkt_1001" }.
The regular expression requires the tkt_ prefix followed by one or more letters, digits, or hyphens. This covers both deterministic seed IDs and UUID-based IDs.
A malformed path such as /api/tickets/not-a-ticket fails before findTicketById() is called. The database is not asked to look up an identifier that does not match the application’s format.
This schema checks the identifier’s shape, not whether the ticket exists. A valid-looking ID such as tkt_missing can still pass Zod and later produce 404 TICKET_NOT_FOUND.
Validating and Normalizing Collection Queries
The collection schema handles search, filters, sorting, and defaults.
q is optional, but when present it is trimmed and must contain between 1 and 100 characters. A value such as " payment " becomes "payment" before the repository receives it.
status and priority accept only the domain-supported values. A query such as status=waiting becomes a validation error rather than reaching SQL construction.
sortBy defaults to "createdAt" and order defaults to "desc". A plain GET /api/tickets therefore produces the complete normalized query object even though those values were omitted from the URL.
Zod’s parsed result is more useful than the raw query string because it already contains trimmed values, validated enums, and defaults.
Validating Ticket Creation Bodies
The creation schema defines length limits, normalization rules, and defaults for optional fields.
title must contain between 3 and 120 characters after trimming. The normalized title is the value eventually inserted into PostgreSQL.
description defaults to an empty string and cannot exceed 1000 characters. Route handlers no longer need to repeat the conditional fallback used in the earlier implementation.
priority defaults to "medium" when omitted. Unsupported values such as "urgent" are rejected by the enum.
requesterId must be a non-empty trimmed string. Zod verifies its shape, while userExists() separately confirms that it references a real database row.
The update schema allows only mutable fields and rejects empty patches.
export const updateTicketBodySchema = z .object({ title: z.string().trim().min(3).max(120).optional(), description: z.string().trim().max(1000).optional(), priority: z.enum(ticketPriorities).optional(), status: z.enum(ticketStatuses).optional(), }) .refine((value) => Object.keys(value).length > 0, { message: "At least one field is required.", });
Every field is optional because PATCH represents a partial update. Sending only { "status": "closed" } leaves the other ticket fields unchanged.
The schema includes only title, description, priority, and status. IDs, requester ownership, assignee data, and timestamps remain outside this general update contract.
.refine() applies a rule to the complete parsed object. It rejects {} because a partial update must still request at least one actual change.
The route no longer needs to build an empty patch object manually and check Object.keys() itself. That rule is now part of the reusable request contract.
Validating Nested Comment Bodies
Comment creation uses the same validation architecture.
authorId must contain a non-empty trimmed string. A later userExists() call determines whether the referenced author actually exists.
message must contain meaningful text after trimming and cannot exceed 500 characters.
A body containing "message": " " fails before the comment repository performs an INSERT.
Client-supplied authorId remains temporary scaffolding. Zod validation and database existence checks do not prove that the caller owns that identity.
Inferring TypeScript Types from Schemas
The schema file exports inferred types alongside the runtime schemas.
export type TicketIdParams = z.infer<typeof ticketIdParamsSchema>;export type TicketListQuery = z.infer<typeof ticketListQuerySchema>;export type CreateTicketBody = z.infer<typeof createTicketBodySchema>;export type UpdateTicketBody = z.infer<typeof updateTicketBodySchema>;export type CreateCommentBody = z.infer<typeof createCommentBodySchema>;
z.infer derives a TypeScript type from the schema. The runtime validation contract and compile-time handler type therefore stay synchronized.
Defaults affect the inferred result. For example, the parsed CreateTicketBody has a definite description and priority, even when the original request omitted them.
Route handlers can read validated values with precise types instead of repeatedly casting raw Express input.
Creating Reusable Validation Middleware
The generic middleware lives in src/middleware/validate-request.ts.
schema.parse() both validates and transforms input. Trimming and defaults are applied before the parsed value is stored.
Conditional object spreads ensure the middleware does not parse sections for which no schema was supplied.
The parsed results are stored in response.locals.validated. This gives later middleware and handlers a shared per-request location for normalized values.
next() is called exactly once after every requested section has parsed successfully.
The middleware intentionally does not catch ZodError. A thrown error enters Express’s error flow and reaches the centralized four-argument error handler.
Preserving Earlier Validated Sections
The current value is especially important for nested routes.
This ordering is still required because Express evaluates routes in declaration order. If the dynamic route came first, the literal word stats could be treated as a ticket ID and then rejected by ticketIdParamsSchema.
Validation improves request boundaries, but it does not replace correct route organization.
Validating Nested Comment Routes
The parent ticket router validates ticketId, verifies the parent exists, and then passes control to the child router.
The request should fail before the repository lookup because it does not follow the tkt_<id> format.
Try an empty update:
PATCH /api/tickets/tkt_1001
{}
The schema refinement should report that at least one field is required.
Finally, try an invalid nested comment:
POST /api/tickets/tkt_1001/comments
{ "authorId": "usr_agent_1", "message": " "}
The body should be rejected before PostgreSQL receives an insert request.
Schema Validation and Database Validation
Zod answers questions such as:
Is requesterId a non-empty string?
Is priority one of the supported enum values?
Does ticketId follow the required format?
Is the update body non-empty?
Is the comment message within its length limit?
Repositories answer different questions:
Does the requester exist?
Does the author exist?
Does the ticket exist?
Can the row be inserted or updated under database constraints?
A body containing "requesterId": "usr_missing" is structurally valid. Zod accepts it, but userExists() rejects it with INVALID_REQUESTER.
Neither check establishes trusted identity. Authentication later replaces client-supplied requester and author IDs with identity derived from verified credentials.
Recap
You’ve replaced repeated route-local parsing with a reusable Zod validation boundary. Schemas now define the supported ticket ID format, normalized collection queries, creation defaults, partial-update rules, and nested comment input.
validateRequest() parses only the requested sections, stores normalized results in response.locals.validated, and preserves values created by earlier middleware. This makes it especially effective for nested routes where a parent validates parameters and a child later validates a body.
Thrown ZodError values flow into the centralized error handler, which produces consistent 400 VALIDATION_ERROR responses with readable field paths. Repositories remain focused on database work, while user and ticket existence checks continue to handle concerns that schemas cannot determine.