Zod in Lambda solves a problem for me that shows up in almost every TypeScript project behind API Gateway sooner or later: the type says the body contains a reservation, but at runtime you receive a string that could hold anything. TypeScript types disappear during compilation. Nobody checks what the client really sends unless you do it yourself.
The usual answer is three places that describe the same thing: an interface in the code, a handful of if checks in the handler and an OpenAPI file someone maintained by hand months ago. By the third new field at the latest, those three no longer agree. In this article we build a small reservation API for a fictional bike rental shop together, where exactly one Zod schema takes care of all three jobs.
In short
With Zod in Lambda you validate the request body at runtime, derive your TypeScript types from it and generate the OpenAPI document with z.toJSONSchema(), all from a single schema. The key is separating the request view from the response view (io “input” and “output”). The biggest pitfall: custom rules written with .refine() don't show up in the generated document.
What you will learn in this article
- Use one Zod schema for validation, TypeScript types and documentation
- Check request bodies in a Lambda handler with safeParse and report errors cleanly
- Generate an OpenAPI 3.1 document from the same schemas
- Pin down the contract with tests and deploy the API with CDK
Why Zod in Lambda? Three truths that drift apart
Zod is a library for describing in TypeScript what your data should look like. From that description, the schema, you get two things: a check at runtime and a static type at development time. Since version 4, Zod can also generate JSON Schema on its own. That means the third truth, your API documentation, comes out of the same schema as well.
For a Lambda function behind an HTTP API this is particularly valuable. The body arrives in the event as text (field body, additionally base64-encoded for binary data). Before you calculate with it, store it or send a message, you have to parse and check it. This boundary between “the outside world” and “your code” is exactly where validation belongs: once, cleanly and with understandable error messages.
One schema, three jobs: validation in the handler, types for the compiler and the OpenAPI document.
The picture shows the principle we are about to implement: the schema sits in the middle. The handler uses it for validation, the compiler derives types from it, and a small script generates the OpenAPI document from it. When the schema changes, all three change with it.
Prerequisites
You don't need much for this tutorial. If you have never deployed a Lambda function with CDK, my article Deploy a Lambda function with CDK and TypeScript is a good warm-up.
- Node.js 22 or newer and npm
- Basic TypeScript knowledge (types,
async/await) - For the deployment an AWS account with a configured AWS CLI; everything up to and including step 5 also runs locally without AWS
- I tested the code with Zod 4.6.7, TypeScript 7.0.2, aws-cdk-lib 2.274.0, Vitest 5.0.5 and Node.js 22.23
Step 1: Create the project
Create an empty folder and install the dependencies. Zod and aws-cdk-lib are runtime dependencies, everything else is only needed during development. CDK uses esbuild to bundle the handler.
mkdir zod-lambda-openapi && cd zod-lambda-openapi npm init -y npm install zod aws-cdk-lib constructs npm install -D typescript aws-cdk tsx vitest esbuild @types/aws-lambda @types/node mkdir src lib bin scripts test Next you need a tsconfig.json and a cdk.json. The configuration is deliberately lean: strict mode and no output files, because CDK and tsx compile the code themselves.
{ "compilerOptions": { "target": "ES2023", "module": "nodenext", "moduleResolution": "nodenext", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "noEmit": true, "types": ["node"] }, "include": ["src", "lib", "bin", "scripts", "test"] } { "app": "npx tsx bin/app.ts" } Add four scripts to your package.json so you don't have to remember every command later:
{ "scripts": { "typecheck": "tsc --noEmit", "test": "vitest run", "openapi": "tsx scripts/openapi.ts", "synth": "cdk synth" } } Step 2: The schema as the single source of truth
Now for the heart of it. In src/schemas.ts you describe what a reservation request may contain, what a confirmed reservation looks like and which shape an error response has.
// src/schemas.ts import { z } from "zod"; export const BikeType = z .enum(["city", "trekking", "ebike", "cargo"]) .meta({ description: "Type of bike" }); export const ReservationRequest = z .strictObject({ bikeType: BikeType, pickupDate: z.iso.date().meta({ description: "Pickup day in the format YYYY-MM-DD", examples: ["2026-11-14"], }), days: z.int().min(1).max(14).meta({ description: "Rental period in days" }), customerEmail: z.email(), helmet: z.boolean().default(false), }) .meta({ title: "New reservation" }); export const Reservation = z .strictObject({ reservationId: z.uuid(), status: z.literal("confirmed"), bikeType: BikeType, pickupDate: z.iso.date(), days: z.int(), helmet: z.boolean(), totalPriceCents: z.int().nonnegative(), }) .meta({ title: "Confirmed reservation" }); export const ErrorResponse = z .object({ message: z.string(), issues: z.array(z.object({ path: z.string(), message: z.string() })), }) .meta({ title: "Error response" }); export type BikeType = z.infer<typeof BikeType>; export type ReservationInput = z.input<typeof ReservationRequest>; export type ReservationRequest = z.output<typeof ReservationRequest>; export type Reservation = z.infer<typeof Reservation>; export type ErrorResponse = z.infer<typeof ErrorResponse>; A few details matter to me here. z.strictObject rejects unknown fields. If someone also sends "discount": 100, they get an error instead of the field being silently ignored. For public APIs this is my default, because typos in field names otherwise slip through unnoticed.
z.iso.date() checks a date in the format YYYY-MM-DD and keeps it as a string. That is intentional, since a JavaScript Date cannot be expressed in JSON Schema (more on that shortly). z.int() only allows whole numbers, and with .meta() you attach descriptions and examples that later show up in the OpenAPI document.
Why z.input and z.output are two different types
The helmet field has a default value. For the client it is therefore optional, but in the handler it is always set after parsing. Zod models this with two types: z.input describes what may go in, z.output (or simply z.infer) describes what comes out after parsing. We will need this difference again for the OpenAPI document.
Step 3: Zod in Lambda – the handler checks the body
First a small pricing function, so the handler has something meaningful to do. The prices are made up, of course.
// src/pricing.ts import type { BikeType } from "./schemas"; const DAILY_RATE_CENTS: Record<BikeType, number> = { city: 1200, trekking: 1500, ebike: 2900, cargo: 3900, }; const HELMET_PER_DAY_CENTS = 300; export function calculatePrice(bikeType: BikeType, days: number, helmet: boolean): number { const helmetCents = helmet ? HELMET_PER_DAY_CENTS * days : 0; return DAILY_RATE_CENTS[bikeType] * days + helmetCents; } The handler reads the body, checks it with safeParse and responds either with 201 and the reservation or with 400 and a list of all errors.
// src/create-reservation.ts import type { APIGatewayProxyEventV2, APIGatewayProxyResultV2 } from "aws-lambda"; import { randomUUID } from "node:crypto"; import { z } from "zod"; import { calculatePrice } from "./pricing"; import { Reservation, ReservationRequest, type ErrorResponse } from "./schemas"; // Zod error messages in English (the default locale, set explicitly here) z.config(z.locales.en()); function json(statusCode: number, body: unknown): APIGatewayProxyResultV2 { return { statusCode, headers: { "content-type": "application/json" }, body: JSON.stringify(body), }; } function badRequest(message: string, error?: z.ZodError): APIGatewayProxyResultV2 { const body: ErrorResponse = { message, issues: (error?.issues ?? []).map((issue) => ({ path: issue.path.join("."), message: issue.message, })), }; return json(400, body); } function readBody(event: APIGatewayProxyEventV2): unknown { const raw = event.isBase64Encoded ? Buffer.from(event.body ?? "", "base64").toString("utf8") : (event.body ?? ""); return JSON.parse(raw); } export const handler = async (event: APIGatewayProxyEventV2): Promise<APIGatewayProxyResultV2> => { let body: unknown; try { body = readBody(event); } catch { return badRequest("The body is not valid JSON."); } const result = ReservationRequest.safeParse(body); if (!result.success) { return badRequest("The reservation is invalid.", result.error); } // From here on result.data is fully typed and helmet has its default value const request = result.data; const reservation = Reservation.parse({ reservationId: randomUUID(), status: "confirmed", bikeType: request.bikeType, pickupDate: request.pickupDate, days: request.days, helmet: request.helmet, totalPriceCents: calculatePrice(request.bikeType, request.days, request.helmet), }); return json(201, reservation); }; I deliberately use safeParse instead of parse here. parse throws an exception for invalid data. If you don't catch it, a bad input turns into a 500, and in your monitoring a client typo suddenly looks like a bug in your code. safeParse, on the other hand, returns a result object. After checking result.success, TypeScript knows that result.data matches the output type exactly. From that line on there is no uncertainty left.
Zod also collects all errors in a single pass. In one response the client learns that the bike, the date and the rental period are wrong, and doesn't have to fix things three times. English messages are Zod's default; for other languages there are locales, for example z.config(z.locales.de()) for German. If you want your own wording, you can pass it directly to a check, such as .min(1, "At least one day"); such messages take precedence over the locale.
I also run the response through a schema (Reservation.parse). It costs hardly any time and guarantees that the handler never returns anything that doesn't match the documentation. Here parse is the right choice: if this check fails, the bug really is on your side, and a 500 is the honest answer.
🎬 Animation: request validation with Zod in Lambda – this animated diagram only runs on my site: watch it on sascha-ms.de
Step 4: Generate OpenAPI from the same Zod schemas
Now for the third truth. z.toJSONSchema() converts a Zod schema into JSON Schema, using the draft 2020-12 dialect by default. OpenAPI 3.1 uses exactly this dialect, so you can drop the result straight into components.schemas.
// src/openapi.ts import { z } from "zod"; import { ErrorResponse, Reservation, ReservationRequest } from "./schemas"; type Io = "input" | "output"; // Convert a Zod schema into JSON Schema (draft 2020-12), as OpenAPI 3.1 expects function component(schema: z.ZodType, io: Io) { const { $schema, ...jsonSchema } = z.toJSONSchema(schema, { io }); return jsonSchema; } const ref = (name: string) => ({ $ref: `#/components/schemas/${name}` }); export function buildOpenApi() { return { openapi: "3.1.0", info: { title: "Bike Rental API", version: "1.0.0" }, paths: { "/reservations": { post: { summary: "Reserve a bike", operationId: "createReservation", requestBody: { required: true, content: { "application/json": { schema: ref("ReservationRequest") } }, }, responses: { "201": { description: "Reservation created", content: { "application/json": { schema: ref("Reservation") } }, }, "400": { description: "Invalid input", content: { "application/json": { schema: ref("ErrorResponse") } }, }, }, }, }, }, components: { schemas: { // Requests describe what the client may send: io "input" ReservationRequest: component(ReservationRequest, "input"), // Responses describe what the server guarantees: io "output" Reservation: component(Reservation, "output"), ErrorResponse: component(ErrorResponse, "output"), }, }, }; } The key parameter is io. For the request I generate the schema with "input", so helmet doesn't appear as a required field. For the responses I use "output", because there the server guarantees that every field is set. It is the same difference as between z.input and z.output, only this time for the documentation.
A small script writes the document to a file:
// scripts/openapi.ts import { mkdirSync, writeFileSync } from "node:fs"; import { buildOpenApi } from "../src/openapi"; mkdirSync("dist", { recursive: true }); writeFileSync("dist/openapi.json", `${JSON.stringify(buildOpenApi(), null, 2)}\n`); console.log("dist/openapi.json written"); npm run openapi Expected result: the file dist/openapi.json is created. Among other things it contains this excerpt, and you can see that helmet is not listed under required for the request:
{ "ReservationRequest": { "type": "object", "properties": { "days": { "type": "integer", "minimum": 1, "maximum": 14, "description": "Rental period in days" }, "helmet": { "default": false, "type": "boolean" } }, "required": [ "bikeType", "pickupDate", "days", "customerEmail" ], "additionalProperties": false, "title": "New reservation" } } You can load the file into Swagger UI, Redoc or Postman, hand it to your frontend team or generate a client from it. If a tool still works with OpenAPI 3.0, there is the option target: "openapi-3.0".
What doesn't end up in the OpenAPI document
You should know two things before relying too heavily on generation. First, custom checks with .refine() do not appear in the JSON Schema. If you check, for example, that the pickup day is not in the past, the handler does this reliably, but the OpenAPI document knows nothing about it. So add such rules to the description as well.
Second, types without a JSON counterpart such as z.date() or z.bigint() make the conversion fail with an error by default. That's a good thing, because only strings and numbers arrive over HTTP anyway. With the option unrepresentable: "any" you could suppress the error, but you lose information. I prefer to stick with z.iso.date() and only convert the date in code when I really need it as a Date.
Step 5: Tests that pin down the contract
Two test files are enough to start with. The first calls the handler directly with a hand-built event, no AWS required:
// test/create-reservation.test.ts import type { APIGatewayProxyEventV2, APIGatewayProxyStructuredResultV2 } from "aws-lambda"; import { describe, expect, it } from "vitest"; import { handler } from "../src/create-reservation"; function event(body: string): APIGatewayProxyEventV2 { return { Source:https://dev.to/sascha-ms/zod-in-lambda-validation-types-and-openapi-from-one-schema-5fn4Related posts
Staff Augmentation Companies in Canada: How to Choose a Resource Augmentation Partner

