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:
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.

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.
Header<> and Query<> fields on the same interface.Min, MaxLen, IsEmail and MatchesRegexp from encore.dev/validate.~encore/clients.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.
Run in your terminal to get started locally.
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);
z.infer gives a type that cannot drift from the check.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.
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 });
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.
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);
whitelist: true strips properties that have no decorator.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.
HonoHono 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;
json, query, param, header and form targets.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.
safeParse call on every route through review or a lint rule.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.
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.
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.
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.