Oct 23, 202610 min read

TypeScript Request Validation for AI Coding Agents

How Express, Fastify, NestJS, Hono and Encore turn a request type into a runtime check, and which setup gives a coding agent one place to change when an API changes.

Every TypeScript backend eventually has a handler that reads req.body, and the type annotation on it says nothing about what actually arrived, because type annotations never change the runtime behavior of a program. Teams fill that gap with a validation library, and the usual result is two descriptions of the same request, an interface the editor checks and a schema the server checks.

That works while one person maintains both, but it breaks down when a coding agent adds a field to the interface in one file and leaves the schema in another file unchanged, so the code compiles, the tests that call the function directly pass, and the first real request with the new field is either rejected or let through unchecked.

That is the kind of "almost right" result that 66 percent of developers in the 2025 Stack Overflow survey named as their biggest frustration with AI tools, and unchecked input is where it turns into a security problem, since in Veracode's 2025 GenAI Code Security Report models introduced an OWASP Top 10 vulnerability in 45 percent of coding tasks and failed to defend against cross-site scripting in 86 percent of relevant samples.

What decides how well a validation setup holds up when an agent is making the changes:

  • Whether the TypeScript type is derived from the runtime schema, or written separately next to it.
  • Whether validation is applied per route, which an agent can forget on a new route, or for every endpoint by default.
  • What the client gets back on a bad request, and whether the error names the field.
  • How much of the validation contract the agent can read in one file.

Request validation at a glance

OptionWhere validation is declaredType and check stay in sync
EncoreThe TypeScript request type of the api() handlerYesthe type is the schema
Express with ZodA Zod schema called inside each handlerWhen the type comes from z.infer
Fastify with TypeBoxA JSON Schema on the route optionsWhen the type comes from Static
NestJS with class-validatorDecorators on a DTO class, run by ValidationPipeYes for the class, if the pipe is registered
Hono with zod-validatorZod middleware on each routeYes, c.req.valid() is typed from the schema
Compare validation details across all five
CapabilityEncoreExpress with ZodFastify with TypeBoxNestJS with class-validatorHono with zod-validator
Applied by defaultEvery endpointOnly where the handler calls itOnly routes with a schemaWherever the pipe is registeredOnly routes with the middleware
Value rulesMin, MaxLen, IsEmail, IsURL and more as type intersectionsZod methodsJSON Schema keywordsDecoratorsZod methods
Headers and queryHeader<> and Query<> fields on the same typeSeparate parsingSeparate headers and querystring schemasSeparate decoratorsSeparate validator targets
Error response400 with the failing fieldWhatever the handler writes400 from Fastify400 from Nest400 from the middleware
InfrastructurePostgres, Pub/Sub, cron and buckets declared in code, provisioned in your AWS or GCPNot providedNot providedNot providedNot provided

Encore: the request type is the validation

Encore

Encore combines a TypeScript backend framework with infrastructure declared in the same code, and its request validation comes straight from the types an endpoint already declares. The parser reads the request interface of each api() handler at build time, and at runtime the server checks incoming requests against it, including value rules written as type intersections, so there is no second schema for an agent to keep in step.

import { api } from "encore.dev/api"; import { Topic } from "encore.dev/pubsub"; import { SQLDatabase } from "encore.dev/storage/sqldb"; import { IsEmail, Min } from "encore.dev/validate"; const db = new SQLDatabase("orders", { migrations: "./migrations" }); export interface OrderPlaced { orderId: number; email: string; } export const orderPlaced = new Topic<OrderPlaced>("order-placed", { deliveryGuarantee: "at-least-once", }); interface CreateOrder { email: string & IsEmail; total: number & Min<1>; } interface Order { id: number; email: string; total: number; } export const create = api( { expose: true, method: "POST", path: "/orders" }, async (req: CreateOrder): Promise<Order> => { const row = await db.queryRow<{ id: number }>` INSERT INTO orders (email, total) VALUES (${req.email}, ${req.total}) RETURNING id`; await orderPlaced.publish({ orderId: row!.id, email: req.email }); return { id: row!.id, email: req.email, total: req.total }; }, );

Sending {"email":"not-an-email","total":0} to this endpoint returns a 400 with "code":"invalid_argument" and a message that names the email field, before the handler runs. When an agent adds a field to CreateOrder, the runtime check, the generated API documentation and the type-safe client all change with it, and a caller in another service that passes the wrong shape fails tsc instead of failing in production.

Application code passes through Encore and infrastructure guardrails to resources provisioned in your own AWS or GCP account.
Encore provisions the infrastructure your application needs in your own AWS or GCP account.

The same file declares the Postgres database and the Pub/Sub topic the endpoint uses. encore run starts Postgres locally along with the topic, so an agent can call the endpoint, see the 400 for a bad payload, and read the trace for a good one through Encore's local MCP server. On deploy, Encore provisions the same resources in your own AWS or GCP account, while the agent declares infrastructure in code and the cloud access sits with Encore's IAM role rather than with the agent.

Key features

  • Request validation generated from TypeScript types, with Header<> and Query<> fields on the same interface.
  • Composable value rules such as Min, MaxLen, IsEmail and MatchesRegexp from encore.dev/validate.
  • Type-checked service-to-service calls through ~encore/clients.
  • Postgres, Pub/Sub, object storage, cron jobs and secrets declared in code and provisioned in your AWS or GCP account. Everything except cron runs locally, and you test a cron job by calling its endpoint directly.

Good to know

Validation covers the shapes TypeScript can express statically, so a check that needs a database lookup, such as whether an email is already registered, still belongs in the handler. Encore is a full framework rather than a validation library, so adopting it in an existing Express or NestJS codebase usually means moving one service at a time. For a benchmark of agents building the same API on all five frameworks, see the best TypeScript backend framework for AI agents.

Get started
brew install encoredev/tap/encore &&
encore app create
Copy

Run in your terminal to get started locally.

Express with Zod: a schema called inside the handler

Express

Express has no validation of its own, and most new Express code pairs it with Zod, which gives a schema that parses at runtime and a type derived from it.

import express from "express"; import { z } from "zod"; const CreateOrder = z.object({ email: z.email(), total: z.number().int().min(1), }); const app = express(); app.use(express.json()); app.post("/orders", (req, res) => { const parsed = CreateOrder.safeParse(req.body); if (!parsed.success) { res.status(400).json({ issues: parsed.error.issues }); return; } // parsed.data is typed as { email: string; total: number } res.status(201).json(parsed.data); }); app.listen(3000);

Key features

  • Zod schemas are plain values, so z.infer gives a type that cannot drift from the check.
  • The error format, status code and logging are entirely yours.
  • The same schemas can validate environment variables, queue messages and form data.

Good to know

req.body is still typed as any, so a route that skips safeParse compiles and accepts anything without Express pointing it out, and agents tend to copy whatever pattern the nearest route uses, which helps when the codebase is consistent and spreads the gap when it is not. For a closer look at agents on Express, see Express vs Encore for AI coding agents.

Try Express →

Fastify with TypeBox: JSON Schema on the route

Fastify

Fastify validates requests against a JSON Schema attached to each route, and TypeBox builds that schema in TypeScript and derives a static type from it.

import Fastify from "fastify"; import { Type, type Static } from "@sinclair/typebox"; const CreateOrder = Type.Object({ email: Type.String({ format: "email" }), total: Type.Integer({ minimum: 1 }), }); type CreateOrder = Static<typeof CreateOrder>; const app = Fastify(); app.post<{ Body: CreateOrder }>( "/orders", { schema: { body: CreateOrder } }, async (req, reply) => { reply.code(201); return req.body; }, ); await app.listen({ port: 3000 });

Key features

  • Validation runs before the handler and returns a 400 automatically.
  • Response schemas speed up serialization and strip unknown fields.
  • The same schemas can feed an OpenAPI document through a plugin.

Good to know

The generic <{ Body: CreateOrder }> and the schema option are two separate inputs, so a route can declare the type without the schema and compile fine with no runtime check. Type providers reduce that duplication, at the cost of one more concept an agent has to know about.

Try Fastify →

NestJS with class-validator: decorators on a DTO

NestJS

NestJS validates request bodies through a ValidationPipe that reads class-validator decorators on a DTO class.

import "reflect-metadata"; import { Body, Controller, Module, Post, ValidationPipe } from "@nestjs/common"; import { NestFactory } from "@nestjs/core"; import { IsEmail, IsInt, Min } from "class-validator"; class CreateOrderDto { @IsEmail() email!: string; @IsInt() @Min(1) total!: number; } @Controller("orders") class OrdersController { @Post() create(@Body() dto: CreateOrderDto) { return dto; } } @Module({ controllers: [OrdersController] }) class AppModule {} const app = await NestFactory.create(AppModule); app.useGlobalPipes(new ValidationPipe({ whitelist: true })); await app.listen(3000);

Key features

  • One DTO class is both the TypeScript type and the validation rules.
  • whitelist: true strips properties that have no decorator.
  • Strong conventions for modules, controllers and providers, which agents can follow.

Good to know

A property without a decorator is either stripped or passed through unchecked, depending on the pipe options, and the pipe itself is configured in main.ts, far from the DTO an agent is editing. Nested objects also need @ValidateNested() and @Type() to be checked at all, which is easy to leave out. See NestJS vs Encore for AI coding agents for how agents handle the rest of the module structure.

Try NestJS →

Hono with zod-validator: middleware per route

Hono

Hono validates through middleware, and the @hono/zod-validator package plugs a Zod schema into a route so that c.req.valid() returns typed data.

import { Hono } from "hono"; import { zValidator } from "@hono/zod-validator"; import { z } from "zod"; const CreateOrder = z.object({ email: z.email(), total: z.number().int().min(1), }); const app = new Hono(); app.post("/orders", zValidator("json", CreateOrder), (c) => { const order = c.req.valid("json"); return c.json(order, 201); }); export default app;

Key features

  • The validated value is typed from the schema, with no separate interface.
  • Validators exist for json, query, param, header and form targets.
  • Runs on Node.js, Bun, Deno, Cloudflare Workers and other runtimes.

Good to know

Validation is opt-in per route, so a new route without zValidator reads c.req.json() untyped and nothing flags it. Hono stays small on purpose, which means the database, queues and deployment are separate decisions, covered in Hono vs Encore for AI coding agents.

Try Hono →

How to choose

  • Encore when you want the request type to be the only description of the request, with validation on every endpoint by default and the database and topics declared in the same code.
  • Express with Zod when you are staying on Express and can enforce a safeParse call on every route through review or a lint rule.
  • Fastify with TypeBox when you want schema validation and fast serialization, and are fine keeping the schema and the type generic aligned.
  • NestJS with class-validator when the team already works in Nest modules and DTOs.
  • Hono with zod-validator for small APIs on edge or multi-runtime targets.

Whatever you pick, give the agent a check it can run, since Anthropic's Claude Code guidance puts that first, and a test that sends a malformed request is the cheapest one to write for validation.

Frequently asked questions

How do I validate API requests in TypeScript when an AI coding agent writes the code?

Keep the request type and the runtime check in one declaration, so the agent cannot change one and forget the other. With Express or Hono that means deriving the type from a Zod schema with z.infer, with Fastify it means a TypeBox schema passed to the route, and with NestJS a DTO class with class-validator decorators and a global ValidationPipe. In Encore the TypeScript interface of the api() handler is the schema, so a field added to the type is validated at runtime and rejected with a 400 response when it is missing or malformed, without a second schema to update.

Does TypeScript validate requests at runtime?

No. TypeScript types are erased when the code is compiled, so an annotation on req.body has no effect on what the server accepts. Runtime validation needs a library such as Zod, a JSON Schema validator, class-validator, or a framework like Encore that generates validation from the types.

Is Zod or class-validator better for AI agents?

Both work. Zod schemas are plain values that produce a type with z.infer, so the type and the check cannot drift apart, while class-validator relies on decorators and a ValidationPipe that has to be registered, which is one more piece an agent can miss in a new module.

Deploy to your cloud on AWS and Google Cloud
without managing infrastructure

Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders