You should validate Lambda events, and you probably know that already. It still tends to slip down the list. The function works, the tests are green, and the sample event always looks the same. Until a partner renames a field, a frontend suddenly sends null, or someone drops a test message into the queue by hand. Then your log shows TypeError: Cannot read properties of undefined, and you spend half an hour hunting for the cause.
This topic keeps coming back to me because TypeScript gives you a false sense of safety at exactly this point. The APIGatewayProxyEventV2 type promises a body of type string. What is inside that string, the compiler cannot know. At runtime you get whatever arrives. In this article I show you how to check that boundary with the Parser from Powertools for AWS Lambda and Zod, so invalid data shows up early, clearly and without collateral damage.
In short
The Powertools Parser checks Lambda events against Zod schemas before your code touches them and hands you real types afterwards. For HTTP APIs you use it as middleware with
safeParseand answer with a 400 yourself. The biggest pitfall is SQS: if you check the whole batch at once, a single broken message drags all the others down with it.
What you will learn in this article
- Why TypeScript types alone do not protect you from broken events
- How to check an HTTP body with an envelope and
safeParseand answer cleanly with a 400 - How to validate SQS messages one by one without losing the whole batch
- How to deploy everything with the Serverless Framework and cover it with
node:test
Why you should validate Lambda events
A Lambda function is a system boundary. Whatever arrives there comes from API Gateway, a queue, EventBridge or a third-party system you do not control. Type definitions such as those from @types/aws-lambda only describe the envelope that AWS guarantees. For the content, meaning your business data, there is no guarantee.
Without a check, this is what typically happens: the code reads a missing field, keeps calculating with undefined or writes half a record to the database. The error then surfaces somewhere completely different. That is why I follow a simple rule: data is checked exactly once, right where it enters the system. After that, the rest of the code may rely on the types.
Zod is a schema library for TypeScript. You describe what valid data looks like and get two things back: a runtime check and a matching TypeScript type. I showed how this works for environment variables in Validate environment variables with Zod. Today it is about the events themselves.
What the Powertools Parser brings along
The Parser is a module of Powertools for AWS Lambda (TypeScript), the toolkit I introduced in Lambda Powertools TypeScript: logs, metrics, traces. It offers three building blocks that you combine:
- Ready-made schemas for the envelopes of AWS events, for example
SqsSchema,EventBridgeSchemaorAPIGatewayProxyEventV2Schema. So you do not have to rebuild the structure of an SQS event yourself. - Envelopes that pull exactly the part you care about out of an event, such as the
bodyof an HTTP request or thedetailof an EventBridge event, and check only that part against your schema. - Helpers such as
JSONStringified, which parses a JSON string first and then validates the result. You need this all the time, because HTTP bodies and SQS messages both arrive as strings.
You can use the Parser as Middy middleware, as a decorator on a class method or the classic way by hand with parse and safeParse. Middy is a small middleware engine for Lambda handlers. The example uses both approaches, because each one suits a different event source.
parse or safeParse?
parse throws a ParseError on invalid data. That is handy when an error should end processing anyway. safeParse, on the other hand, does not throw. It returns an object with success: true and data, or with success: false and error. For an HTTP API you almost always want the second option, because a validation error there is not a server error but a 400 response to the caller.
Two checkpoints: answer at once when synchronous, sort out each message on its own when asynchronous.
The example: rental requests for a bike rental shop
We build a small service for a fictional bike rental shop. Rental requests come in through an HTTP API, and requests from a partner portal land in an SQS queue. Both paths share the same schema. A request needs a bike ID in the format BK-0042, an email address, start and end as ISO timestamps and, optionally, a helmet.
Prerequisites
- Node.js 22 or newer and npm
- An AWS account and Serverless Framework v4 (for the deployment, the tests run without it)
- Basic knowledge of TypeScript and Lambda
Step 1: Set up the project
mkdir bike-rental-validation && cd bike-rental-validation npm init -y npm pkg set type=module npm install @aws-lambda-powertools/parser zod @middy/core npm install -D typescript @types/node @types/aws-lambda One thing about versions: the Parser in version 2.36 expects Zod 4 as a peer dependency. If your project still uses Zod 3, plan that upgrade first. The tsconfig.json is deliberately plain:
{ "compilerOptions": { "target": "ES2024", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": true, "outDir": "dist", "rootDir": ".", "skipLibCheck": true, "types": ["node"] }, "include": ["src", "test"] } Step 2: The schema as the single source of truth
The schema describes the business data and nothing else. It knows nothing about HTTP or SQS, and that is exactly what makes it reusable. With .refine you also check a rule across several fields: the end must be after the start. z.infer turns it into the TypeScript type, which you never have to maintain by hand again.
// src/rental-request.ts import { z } from 'zod'; // A rental request for the bike rental shop – this is what a valid order looks like export const RentalRequestSchema = z .object({ bikeId: z.string().regex(/^BK-\d{4}$/, 'bikeId must look like BK-0042'), customerEmail: z.email(), from: z.iso.datetime({ offset: true }), until: z.iso.datetime({ offset: true }), helmet: z.boolean().default(false), }) .refine((r) => Date.parse(r.until) > Date.parse(r.from), { message: 'until must be after from', path: ['until'], }); export type RentalRequest = z.infer<typeof RentalRequestSchema>; Validate the HTTP body with an envelope and safeParse
For the HTTP API I use the Parser as middleware. The ApiGatewayV2Envelope checks the envelope of the HTTP API event and passes only the body on to your schema. Since the body is a string, I wrap the schema in JSONStringified. With safeParse: true, the handler receives a result object instead of the event and decides on its own how to respond.
Step 3: Write the HTTP handler
// src/http-handler.ts import middy from '@middy/core'; import { parser } from '@aws-lambda-powertools/parser/middleware'; import { ApiGatewayV2Envelope } from '@aws-lambda-powertools/parser/envelopes/api-gatewayv2'; import { JSONStringified } from '@aws-lambda-powertools/parser/helpers'; import type { ParsedResult } from '@aws-lambda-powertools/parser/types'; import type { APIGatewayProxyResultV2 } from 'aws-lambda'; import { z } from 'zod'; import { RentalRequestSchema, type RentalRequest } from './rental-request.js'; const json = (statusCode: number, body: unknown): APIGatewayProxyResultV2 => ({ statusCode, headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), }); const createRental = async ( event: ParsedResult<unknown, RentalRequest>, ): Promise<APIGatewayProxyResultV2> => { if (!event.success) { // The cause is the ZodError listing every field that does not match const cause = event.error.cause; const issues = cause instanceof z.ZodError ? cause.issues.map((i) => ({ field: i.path.slice(1).join('.'), message: i.message })) : [{ field: '', message: 'Body is not valid JSON' }]; return json(400, { error: 'Invalid rental request', issues }); } const rental = event.data; // fully typed from here on return json(202, { status: 'accepted', bikeId: rental.bikeId, helmet: rental.helmet }); }; export const handler = middy(createRental).use( parser({ schema: JSONStringified(RentalRequestSchema), envelope: ApiGatewayV2Envelope, safeParse: true, }), ); Two details matter to me here. First, on errors the handler returns a list of the affected fields, so the frontend can show them right next to the form. The paths start with body, which is why I cut off the first element. Second, the service answers with 202 instead of 201, because the actual reservation happens asynchronously. That fits the pattern I also use for receiving webhooks with Lambda.
An invalid request then gets this answer from the API:
{ "error": "Invalid rental request", "issues": [ { "field": "bikeId", "message": "bikeId must look like BK-0042" }, { "field": "until", "message": "until must be after from" } ] } Also keep an eye on isBase64Encoded. For JSON requests to an HTTP API the body is usually plain text. If a client sends binary data or an unusual content type, API Gateway may encode the body as Base64, and JSONStringified fails. For a pure JSON API that is a welcome 400 response; for file uploads you need a different approach.
Check SQS messages one by one instead of losing the batch
Now comes the part that many examples leave out. For SQS there is an SqsEnvelope that checks all messages of a batch against your schema at once. It looks elegant but has a side effect: if even one message is invalid, the check fails for the whole batch. If the function then throws, Lambda makes all messages visible again, including the nine valid ones. They keep coming back to your function until they end up in the dead-letter queue (DLQ) together with the broken one.
The fix is a division of labour. You check the envelope once with SqsSchema, because if that is wrong, something is fundamentally broken anyway. After that you check every message on its own with safeParse. You report invalid messages through the partial batch response batchItemFailures. Lambda then deletes only the successful messages from the queue.
Step 4: Write the queue handler
// src/queue-handler.ts import { SqsSchema } from '@aws-lambda-powertools/parser/schemas/sqs'; import { JSONStringified } from '@aws-lambda-powertools/parser/helpers'; import type { SQSBatchResponse } from 'aws-lambda'; import { RentalRequestSchema, type RentalRequest } from './rental-request.js'; const RentalMessage = JSONStringified(RentalRequestSchema); export async function reserveBike(rental: RentalRequest): Promise<void> { // Placeholder for the real work, e.g. a DynamoDB write console.log(JSON.stringify({ msg: 'reserved', bikeId: rental.bikeId })); } export const handler = async (event: unknown): Promise<SQSBatchResponse> => { // 1. Check the envelope: is this an SQS event at all? const { Records } = SqsSchema.parse(event); const batchItemFailures: SQSBatchResponse['batchItemFailures'] = []; // 2. Check every message on its own so one broken message does not block the whole batch for (const record of Records) { const result = RentalMessage.safeParse(record.body); if (!result.success) { console.warn( JSON.stringify({ msg: 'invalid message', messageId: record.messageId, issues: result.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`), }), ); batchItemFailures.push({ itemIdentifier: record.messageId }); continue; } try { await reserveBike(result.data); } catch { batchItemFailures.push({ itemIdentifier: record.messageId }); } } return { batchItemFailures }; }; When deciding what happens to an invalid message, there are two defensible options. Either you drop it right away, write a log entry and count a metric. Or you report it as failed so it lands in the DLQ after a few attempts. I usually go for the second option, even though retries do not change anything for a validation error. My reason: in the DLQ I can look at the message later, fix it and replay it. A dropped log line, by contrast, is quickly forgotten.
FIFO queues follow a special rule, by the way. There you stop after the first failure and report that message and all following ones as failed, otherwise the order gets mixed up. The code above is meant for standard queues.
🎬 Flow: SQS batch with one invalid message – this animated diagram only runs on my site: watch it on sascha-ms.de
Deployment with the Serverless Framework
Step 5: Create serverless.yml
For the infrastructure I use the Serverless Framework this time. Version 4 bundles TypeScript on its own with esbuild, so you do not need an extra plugin. The key line is functionResponseType: ReportBatchItemFailures on the SQS event. Without it, Lambda ignores your batchItemFailures and treats every return value as success.
# serverless.yml service: bike-rental-validation frameworkVersion: '4' provider: name: aws runtime: nodejs24.x region: eu-central-1 architecture: arm64 memorySize: 512 logRetentionInDays: 14 build: esbuild: bundle: true minify: true sourcemap: true functions: createRental: handler: src/http-handler.handler events: - httpApi: method: POST path: /rentals reserveBike: handler: src/queue-handler.handler timeout: 30 events: - sqs: arn: !GetAtt RentalQueue.Arn batchSize: 10 functionResponseType: ReportBatchItemFailures resources: Resources: RentalQueue: Type: AWS::SQS::Queue Properties: VisibilityTimeout: 180 RedrivePolicy: deadLetterTargetArn: !GetAtt RentalDlq.Arn maxReceiveCount: 5 RentalDlq: Type: AWS::SQS::Queue Properties: MessageRetentionPeriod: 1209600 Two values come straight from the AWS recommendations. The queue visibility timeout is six times the function timeout, so 180 seconds for a 30-second timeout. And maxReceiveCount is set to 5, so a message gets a few attempts before it moves to the DLQ.
Step 6: Deploy and try it out
npx serverless deploy curl -s -X POST "https://<your-api-id>.execute-api.eu-central-1.amazonaws.com/rentals" \ -H "content-type: application/json" \ -d '{"bikeId":"BK-0042","customerEmail":"kim@example.org","from":"2026-11-07T09:00:00+01:00","until":"2026-11-07T17:00:00+01:00"}' curl -s -X POST "https://<your-api-id>.execute-api.eu-central-1.amazonaws.com/rentals" \ -H "content-type: application/json" \ -d '{"bikeId":"42"}' The first request returns {"status":"accepted","bikeId":"BK-0042","helmet":false}. The default value for helmet comes from the schema, by the way. The second request gets a 400 listing every missing field. To see how the queue handler behaves, send a broken message with aws sqs send-message and watch it show up in the DLQ after five attempts.
Tests: validate Lambda events without an AWS account
A nice side effect of this split is that you can test everything locally. I use the built-in Node.js test runner so no further dependency sneaks in. The events are built by hand and reduced to the fields the schemas require.
// test/handlers.test.ts import { test } from 'node:test'; import assert from 'node:assert/strict'; import type { Context } from 'aws-lambda'; import { handler as httpHandler } from '../src/http-handler.js'; import { handler as queueHandler } from '../src/queue-handler.js'; const valid = { bikeId: 'BK-0042', customerEmail: 'kim@example.org', from: '2026-11-07T09:00:00+01:00', until: '2026-11-07T17:00:00+01:00', }; const httpEvent = (body: string) => ({ version: '2.0', routeKey: 'POST /rentals', rawPath: '/rentals', rawQueryString: '', headers: { 'content-type': 'application/json' }, requestContext: { accountId: '123456789012', apiId: 'abc123', domainName: 'abc123.execute-api.eu-central-1.amazonaws.com', domainPrefix: 'abc123', http: { method: 'POST', path: '/rentals', protocol: 'HTTP/1.1', sourceIp: '203.0.113.10', userAgent: 'test' }, requestId: 'req-1', routeKey: 'POST /rentals', stage: '$default', time: '07/Nov/2026:09:00:00 +0000', timeEpoch: 1793955600000, }, body, isBase64Encoded: false, }); const sqsRecord = (messageId: string, body: string) => ({ messageId, receiptHandle: 'handle-' + messageId, body, attributes: { ApproximateReceiveCount: '1', SentTimestamp: '1793955600000', SenderId: 'AIDAEXAMPLE', ApproximateFirstReceiveTimestamp: '1793955600001', }, messageAttributes: {}, md5OfBody: 'd41d8cd98f00b204e9800998ecf8427e', eventSource: 'aws:sqs', eventSourceARN: 'arn:aws:sqs:eu-central-1:123456789012:rental-requests', awsRegion: 'eu-central-1', }); const ctx = {} as Context; test('HTTP: valid request returns 202 and applies the default', async () => { const res = (await httpHandler(httpEvent(JSON.stringify(valid)) as never, ctx)) as { statusCode: number; body: string }; assert.equal(res.statusCode, 202); assert.equal(JSONSource:https://dev.to/sascha-ms/validate-lambda-events-with-powertools-parser-and-zod-9nlRelated posts

AAEON PICO-PTH4 Specs: Panther Lake 180 TOPS Pico-ITX SBC
What broke when I started running ten Claude Code sessions at once

