Skip to content
Home
Blog
Notes
Back to Blog

Part 3 of 3 · Cdk Notifier

Example how to use zod with CDK serverless v2

August 19, 2023•7 min read

CDK serverless v2 is for using type saftey develepmonet base on schemas like openAPI. This post is a example how to use zod with CDK serverless v2

awscdkprojenzod

Table of Contents

  • Type checking without zod
  • Type checking with zod
  • Implementation
  • openApi
  • onetable
  • Integration into the file creation workflow
  • Code

The AWS CDK Serverless Toolsuite from Thorsten Hoeger helps, among others, to deploy an API Gateway from OpenApi specs and a DynamoDb from DynamoDb onetable data modeling. The advantage is to leverage the type safety from Typscript generated from these files.

That helps during the development cycle, but a runtime Typescript is Javascript without any type checking. This post is about enhancing this setup with zod to validate the types during runtime.

The workflow so far is to create a definition and generate Typescript types from that. Now the workflow has a step before to create a zod schema and derive the definitions from the zod schema.

Type checking without zod

As you can see, the types are available at development time via the OpenApi spec.

add Todo title type

This is because of the defined components in this openApi spec

components: schemas: Todo: type: object required: - id - state - title - description - lastUpdate properties: id: type: string state: type: string title: type: string description: type: string lastUpdate: type: string format: date-time AddTodo: type: object required: - title - description properties: title: type: string description: type: string

But that didn't prevent you from using the false type during runtime.

add Todo title as number

Type checking with zod

With a one-line parsing command, zod checks all the types.

add Todo zod parsing

Furthermore, zod has some string-specific validations that can check if the email is valid.

notificationsEmail: z.string().email(),

add Todo validation result

Implementation

To implement that, first, the zod schemas are needed. This setup has three schemas. One for the API request, one for the API response and one how the data is stored.

With zod it's possible to reference existing schema an extend fields or omit some.

import * as z from "zod"; export const schemaTodoApi = z.object({ id: z.string().uuid(), state: z.enum(["OPEN", "IN PROGRESS", "DONE"]).default("OPEN"), title: z.string(), finishedInDays: z.number().int().positive(), notificationsEmail: z.string().email(), description: z.string().optional(), lastUpdate: z.string().datetime(), }); export const schemaAddTodoApi = schemaTodoApi.omit({ id: true, state: true, lastUpdate: true, }); export const schemaTodoDdb = schemaTodoApi .extend({ lastUpdated: z.string().datetime(), }) .omit({ lastUpdate: true });

openApi

To create the openApi spec I'm using the @asteasolutions/zod-to-openapi package. zod has listed some more packages here which can be used.

The definition of the apiSpec is now created via Typescript as you can see here and generate a yaml file.

import fs from "node:fs"; import { extendZodWithOpenApi, OpenAPIRegistry, OpenApiGeneratorV3, } from "@asteasolutions/zod-to-openapi"; import yaml from "js-yaml"; import * as z from "zod"; import { schemaAddTodoApi, schemaTodoApi } from "./schema-todo"; extendZodWithOpenApi(z); const registry = new OpenAPIRegistry(); const apiKeyComponent = registry.registerComponent( "securitySchemes", "api_key", { type: "apiKey", name: "x-api-key", in: "header", }, ); registry.register("Todo", schemaTodoApi.openapi({})); registry.register("AddTodo", schemaAddTodoApi.openapi({})); registry.registerPath({ method: "get", path: "/todos", summary: "return list of todos", tags: ["admin"], security: [{ [apiKeyComponent.name]: [] }], operationId: "getTodos", responses: { 200: { description: "successful operation", content: { "application/json": { schema: { type: "array", items: { $ref: "#/components/schemas/Todo", }, }, }, "text/calendar": { schema: { type: "string", }, }, }, }, }, }); registry.registerPath({ method: "post", path: "/todos", summary: "add new todo", tags: ["admin"], security: [{ [apiKeyComponent.name]: [] }], operationId: "addTodo", requestBody: { required: true, content: { "application/json": { schema: { $ref: "#/components/schemas/AddTodo", }, }, }, }, responses: { 201: { description: "successful operation", content: { "application/json": { schema: { $ref: "#/components/schemas/Todo", }, }, }, }, 401: { description: "you are not logged in", content: {}, }, 403: { description: "you are not authorized to add todos", content: {}, }, }, "x-codegen-request-body-name": "body", }); registry.registerPath({ method: "post", path: "/todos/{id}", summary: "get a todo by its id", tags: ["admin"], security: [{ [apiKeyComponent.name]: [] }], operationId: "getTodoById", responses: { 200: { description: "successful operation", content: { "application/json": { schema: { $ref: "#/components/schemas/Todo", }, }, }, }, 401: { description: "you are not logged in", content: {}, }, 403: { description: "you are not authorized to add todos", content: {}, }, }, }); registry.registerPath({ method: "delete", path: "/todos/{id}", summary: "delete a todo", tags: ["admin"], security: [{ [apiKeyComponent.name]: [] }], operationId: "removeTodo", responses: { 200: { description: "successful operation", content: {}, }, }, }); const generator = new OpenApiGeneratorV3(registry.definitions); const generatorDocument = generator.generateDocument({ openapi: "3.0.1", info: { version: "1.0", title: "Serverless Demo with zod", }, tags: [ { name: "info", }, { name: "admin", }, ], }); const yamlString = yaml.dump(generatorDocument, { indent: 2 }); fs.writeFileSync("./src/definitions/myapi-zod.yaml", yamlString);

onetable

Unfortunately, for onetable didn't exist a npm package. So the conversion is made from scratch. This is how it looks like.

import fs from "node:fs"; import { z } from "zod"; import { schemaTodoDdb } from "./schema-todo"; const modelTodoDdb = { PK: { type: "string", value: "TODO#${id}", }, SK: { type: "string", value: "TODO#${id}", }, id: { type: "string", required: true, generate: "uuid", }, GSI1PK: { type: "string", value: "TODOS", }, GSI1SK: { type: "string", value: "${state}#${title}", }, }; const schemaTodoValues = schemaTodoDdb.keyof().Values; const modelTodoFields = Object.keys(schemaTodoValues).reduce((acc, key) => { const keyOfSchemaTodoKeyValues = key as keyof typeof schemaTodoValues; const shapeType = schemaTodoDdb.shape[keyOfSchemaTodoKeyValues]; const { type, required, generate, enumValues, defaultValue } = deriveAttributes(shapeType); return { ...acc, [key]: { type: type, required: required, generate: generate, enum: enumValues, default: defaultValue, }, }; }, {}); function deriveAttributes(shapeType: z.ZodType<any, any>) { let type = ""; let required = false; let generate = undefined; let enumValues = [] as string[]; let defaultValue = undefined; if (shapeType === undefined) { throw new Error("type is undefined"); } else if (shapeType instanceof z.ZodString) { type = "string"; required = true; generate = shapeType.isUUID ? "uuid" : undefined; } else if (shapeType instanceof z.ZodNumber) { type = "number"; required = true; } else if (shapeType instanceof z.ZodEnum) { required = true; type = "string"; enumValues = shapeType._def.values; } else if (shapeType instanceof z.ZodDefault) { required = true; defaultValue = shapeType._def.defaultValue(); const { type: typeInnerType, enumValues: enumInnerType } = deriveAttributes( shapeType._def.innerType, ); type = typeInnerType; enumValues = enumInnerType as string[]; } else if (shapeType instanceof z.ZodOptional) { required = false; const { type: typeInnerType } = deriveAttributes(shapeType._def.innerType); type = typeInnerType; } else { console.log("shapeType", shapeType); throw new Error("type is not supported"); } return { type, required: required ? true : undefined, generate, enumValues: enumValues && enumValues.length > 0 ? enumValues : undefined, defaultValue, }; } export const modelTodo = { ...modelTodoDdb, ...modelTodoFields, }; const onetable = { indexes: { primary: { hash: "PK", sort: "SK", }, GSI1: { hash: "GSI1PK", sort: "GSI1SK", project: "all", }, LSI1: { type: "local", sort: "lastUpdated", project: ["id", "lastUpdated", "title"], }, }, models: { Todo: modelTodo, }, version: "0.1.0", format: "onetable:1.1.0", queries: {}, }; fs.writeFileSync( "./src/definitions/mymodel-zod.json", JSON.stringify(onetable, null, 2), );

Integration into the file creation workflow

The definition files can now be generated based on a zod schema. So that that will happen together with generating the files from the spec the projen.ts file need to be enhanced. This will create two commands before.

const taskDefinitionsCreation = project.addTask("definitionsCreation", { steps: [ { exec: "ts-node ./src/zod/openapi.ts" }, { exec: "ts-node ./src/zod/onetable.ts" }, ], }); project.defaultTask!.prependSpawn(taskDefinitionsCreation);

Than the steps look like this.

"default": { "name": "default", "description": "Synthesize project files", "steps": [ { "spawn": "definitionsCreation" }, { "exec": "ts-node --project tsconfig.dev.json .projenrc.ts" }, { "spawn": "generate:api:myapi" } ] },

Now with the command npm run projen the definition file are created derived from zod and from that on the workflow is like before.

Code

https://github.com/JohannesKonings/cdk-serverless-v2-demo

Published on August 19, 2023

Share:Bluesky Icon
← PreviousNext: Use cdk-notifier to compare changes in pull requests →
Categories: aws

Related Posts

Feb 2, 2026

TanStack AI with AWS Bedrock on TanStack Start (simple example)

Jan 11, 2026

Tag log buckets created by AWS CDK for third party tools

Jan 8, 2026

Using Server Sent Events (SSE) to sync Tanstack Db from AWS DynamoDB

RSS|

© 2026 Johannes Konings. All rights reserved.