DEV Community

Cover image for Zod in Lambda: Validation, Types and OpenAPI From One Schema
Sascha Mirow-Stanitzok
Sascha Mirow-Stanitzok

Posted on Originally published at sascha-ms.de

Zod in Lambda: Validation, Types and OpenAPI From One Schema

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.

Zod in Lambda: one Zod schema provides runtime validation in the handler, TypeScript types and OpenAPI docs

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
Enter fullscreen mode Exit fullscreen mode

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"]
}
Enter fullscreen mode Exit fullscreen mode
{ "app": "npx tsx bin/app.ts" }
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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>;
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
};
Enter fullscreen mode Exit fullscreen mode

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"),
      },
    },
  };
}
Enter fullscreen mode Exit fullscreen mode

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");
Enter fullscreen mode Exit fullscreen mode
npm run openapi
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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 { body, isBase64Encoded: false } as APIGatewayProxyEventV2;
}

async function call(body: unknown) {
  const raw = typeof body === "string" ? body : JSON.stringify(body);
  const result = (await handler(event(raw))) as APIGatewayProxyStructuredResultV2;
  return { status: result.statusCode, body: JSON.parse(String(result.body)) };
}

const valid = {
  bikeType: "ebike",
  pickupDate: "2026-11-14",
  days: 3,
  customerEmail: "kim@example.com",
};

describe("POST /reservations", () => {
  it("creates a reservation and sets the default value for helmet", async () => {
    const { status, body } = await call(valid);
    expect(status).toBe(201);
    expect(body.helmet).toBe(false);
    expect(body.totalPriceCents).toBe(8700);
    expect(body.reservationId).toMatch(/^[0-9a-f-]{36}$/);
  });

  it("adds the helmet per day", async () => {
    const { body } = await call({ ...valid, helmet: true });
    expect(body.totalPriceCents).toBe(9600);
  });

  it("reports all errors at once with their path", async () => {
    const { status, body } = await call({ ...valid, bikeType: "bmx", days: 0 });
    expect(status).toBe(400);
    const paths = body.issues.map((issue: { path: string }) => issue.path);
    expect(paths).toEqual(["bikeType", "days"]);
  });

  it("rejects unknown fields", async () => {
    const { status, body } = await call({ ...valid, discount: 100 });
    expect(status).toBe(400);
    expect(body.issues[0].message).toMatch(/Unrecognized key/);
  });

  it("rejects broken JSON", async () => {
    const { status, body } = await call("{ kein json");
    expect(status).toBe(400);
    expect(body.message).toBe("The body is not valid JSON.");
  });
});
Enter fullscreen mode Exit fullscreen mode

The second checks the generated OpenAPI document. It raises the alarm if someone accidentally removes the default value or the descriptions get lost:

// test/openapi.test.ts
import { describe, expect, it } from "vitest";
import { buildOpenApi } from "../src/openapi";

describe("OpenAPI from the Zod schemas", () => {
  const { components } = buildOpenApi();

  it("makes fields with a default value optional in the request", () => {
    const request = components.schemas.ReservationRequest;
    expect(request.required).not.toContain("helmet");
    expect(request.additionalProperties).toBe(false);
  });

  it("guarantees every field in the response", () => {
    expect(components.schemas.Reservation.required).toContain("helmet");
  });

  it("takes descriptions from .meta()", () => {
    const days = components.schemas.ReservationRequest.properties?.days as { description?: string };
    expect(days.description).toBe("Rental period in days");
  });
});
Enter fullscreen mode Exit fullscreen mode
npm run typecheck
npm test
Enter fullscreen mode Exit fullscreen mode

Expected result: the type check finishes without output, and Vitest reports Tests 8 passed (8).

Step 6: Deploy with CDK and try it out

This time I use AWS CDK for the infrastructure. The stack consists of a Lambda function that CDK bundles with esbuild, a log group with one week of retention and an HTTP API with a single route. I explain in more detail how these building blocks fit together in Build an HTTP API with API Gateway and Lambda in CDK.

// lib/rental-stack.ts
import { CfnOutput, Duration, Stack, type StackProps } from "aws-cdk-lib";
import { HttpApi, HttpMethod } from "aws-cdk-lib/aws-apigatewayv2";
import { HttpLambdaIntegration } from "aws-cdk-lib/aws-apigatewayv2-integrations";
import { Architecture, Runtime } from "aws-cdk-lib/aws-lambda";
import { NodejsFunction } from "aws-cdk-lib/aws-lambda-nodejs";
import { LogGroup, RetentionDays } from "aws-cdk-lib/aws-logs";
import type { Construct } from "constructs";
import * as path from "node:path";

export class RentalStack extends Stack {
  constructor(scope: Construct, id: string, props?: StackProps) {
    super(scope, id, props);

    const createReservation = new NodejsFunction(this, "CreateReservation", {
      entry: path.join(__dirname, "../src/create-reservation.ts"),
      handler: "handler",
      runtime: Runtime.NODEJS_22_X,
      architecture: Architecture.ARM_64,
      memorySize: 256,
      timeout: Duration.seconds(5),
      logGroup: new LogGroup(this, "CreateReservationLogs", {
        retention: RetentionDays.ONE_WEEK,
      }),
      bundling: { minify: true, sourceMap: true, target: "node22" },
    });

    const api = new HttpApi(this, "RentalApi");
    api.addRoutes({
      path: "/reservations",
      methods: [HttpMethod.POST],
      integration: new HttpLambdaIntegration("CreateReservationIntegration", createReservation),
    });

    new CfnOutput(this, "ApiUrl", { value: api.apiEndpoint });
  }
}
Enter fullscreen mode Exit fullscreen mode
// bin/app.ts
import { App } from "aws-cdk-lib";
import { RentalStack } from "../lib/rental-stack";

const app = new App();
new RentalStack(app, "BikeRentalStack");
Enter fullscreen mode Exit fullscreen mode

Before the first deployment to an account and region, CDK has to create its helper resources once (bootstrap). After that a single command is enough:

npx cdk bootstrap
npx cdk deploy
Enter fullscreen mode Exit fullscreen mode

At the end CDK prints the API URL as ApiUrl. Put it in a variable and send one valid and one invalid request:

API_URL="https://abc123.execute-api.eu-central-1.amazonaws.com"
curl -s -X POST "$API_URL/reservations" -H "content-type: application/json" -d '{"bikeType":"ebike","pickupDate":"2026-11-14","days":3,"customerEmail":"kim@example.com","helmet":true}'
curl -s -X POST "$API_URL/reservations" -H "content-type: application/json" -d '{"bikeType":"bmx","pickupDate":"14.11.2026","days":0,"customerEmail":"kim@example.com"}'
Enter fullscreen mode Exit fullscreen mode

The expected result for the first request is a reservation with a new ID and "totalPriceCents":9600. The second returns status 400 and all three errors at once:

{
  "message": "The reservation is invalid.",
  "issues": [
    {
      "path": "bikeType",
      "message": "Invalid option: expected one of \"city\"|\"trekking\"|\"ebike\"|\"cargo\""
    },
    {
      "path": "pickupDate",
      "message": "Invalid ISO date"
    },
    {
      "path": "days",
      "message": "Too small: expected number to be >=1"
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

When you're done, clean up with npx cdk destroy.

Common mistakes with Zod in Lambda

The typical pitfalls with Zod in Lambda functions have less to do with the library than with the place where it is used.

parse without try/catch

If you use parse and don't catch the exception, every invalid request becomes a 500. That skews error rates and can trigger alarms even though your code works correctly. For input from outside, safeParse is therefore the better choice.

Defining the schema inside the handler

If the schema lives in the handler file, the OpenAPI script can only import it by loading the handler too, including side effects like z.config. A separate schemas.ts file without AWS dependencies keeps both sides cleanly apart.

Underestimating bundle size

Zod is not a lightweight. If you bundle the example minified with esbuild, the handler ends up at around 450 KB. For most backends that is not critical, and the Zod documentation sees it similarly. However, if you care about every millisecond of cold start, take a look at Zod Mini: same features, but a functional API that tree-shakes much better.

Revealing too much in error messages

The error list should help the client, not expose your internals. Return the path and the message, but no stack traces and not the complete input. Email addresses in particular don't belong in logs unless you really need them.

Costs and security

For this example you only pay for Lambda invocations, HTTP API requests and CloudWatch Logs. For a few test calls that stays in the cent range or falls entirely within the free tier. You can play through how memory size and duration affect the bill with my Lambda cost calculator. Validation saves money too, by the way: a request that ends with 400 after a few milliseconds costs less than one that only fails in the database.

On security, one distinction matters to me. Validation answers the question “Is this input valid?”, not “Is this client allowed to do this?”. The route in this example is open. A real application needs authentication in front of it, for example a JWT authorizer on the HTTP API, and depending on load some throttling.

My take

By now I consider Zod at the boundary of a Lambda function a must, not a nice-to-have. The effort is small, and in return you get three things that would otherwise be maintained separately: validation, types and documentation. Especially in teams where frontend and backend work separately, an OpenAPI document that can't lie is worth a lot.

Where I would push back is the idea of describing every internal object with Zod from now on. Data you create yourself and only pass around inside your own code is well served by plain TypeScript types. Zod belongs at the edges: HTTP bodies, SQS messages, EventBridge events, responses from third-party systems and also the output of language models. I show what that looks like for structured responses from Claude in Bedrock Structured Outputs with Claude and TypeScript.

For me, the .refine() point is the only real catch. If you have many business rules, you have to accept that the generated document describes the shape and not every rule. I can live with that as long as the descriptions are well maintained.

FAQ

Is Zod too slow for Lambda?

Usually not. Checking a small request body takes well under a millisecond. Bundle size during cold start is more noticeable; if it bothers you, Zod Mini is the leaner variant.

Do I still need an extra library for OpenAPI?

Not for the schemas anymore. Since Zod 4, z.toJSONSchema() generates JSON Schema itself. You add paths, status codes and metadata as a plain object like in the example, or use a library that does it for you.

What is the difference between parse and safeParse?

parse returns the validated data and throws an exception on errors. safeParse never throws; it returns an object with success and either data or error. For input from outside, safeParse is usually the better choice.

Does this also work with the Serverless Framework?

Yes. Schemas, handler and OpenAPI script are independent of the infrastructure. Instead of the CDK stack, you describe the function and HTTP API in your serverless.yml, and the code stays the same.

Your next step

Pick an existing Lambda function that accepts a body and replace the manual checks with a Zod schema in its own file. Generate the JSON Schema from it once and compare it with your current documentation. I bet you'll find at least one field that is no longer accurate. Which part of your system would you protect with a schema first: HTTP bodies, queue messages or responses from third-party systems?

Sources

This post was created with AI assistance.


👉 Next step: Curious what your own Lambda functions cost? Try my Lambda cost calculator – it runs right in your browser.

Originally published on sascha-ms.de. More articles on serverless, AI in everyday development and leading tech teams: sascha-ms.de/en

Top comments (0)