Typesafe Environment Variables via AWS CDK
Defining type safe access to environment variables within AWS Lambda function runtime code is relatively straightforward. What often gets overlooked is the type safety (or the “contract”) between the infrastructure code – that sets the environment variables – and the Lambda runtime code.
Luckily that can be solved in AWS Cloud Development Kit (CDK) with schema validation tools like Zod and some TypeScript magic. The end result? More maintainable and less error-prone codebase with improved developer experience.
You may skip ahead if you already know how to set environment variables with CDK and parse & validate them with Zod in the Lambda runtime code.
Let's get the basics out of the way first: Building AWS Lambda functions usually ends up requiring some Environment Variables (in addition to the default ones set by AWS Lambda runtime).
With AWS CDK setting environment variables into Lambda function is quite straightforward:
Somewhere in your CDK stack...
const fn = new NodejsFunction(this, 'MyFunction', { environment: { // This could be also a reference to another CDK resource // like bucket.bucketName etc, but let's keep it simple for now: MY_ENV_VAR: 'my-awesome-value', },});You can use .addEnvironment(key, value) method to add environment variables one by one, but I recommend instead to define all environment variables in one place using the environment property.
Once that is done, then somewhere in your Lambda function runtime code you would access the environment variable value via process.env.MY_ENV_VAR, but:
- You have to do type checking as it's type is either
stringorundefinedbecause there is no guarantee the value is set! This can easily happen due to following reasons for example:- you forgot to set that specific environment variable in the CDK code
- you mistyped the variable name somewhere
- you changed the variable name in CDK, but forgot to update the runtime code – or vice versa
- Any additional validation would have to be done separately, for example:
- start with a prefix
"my-" - have minimum length of
8 - to be in
kebab-case
- start with a prefix
Assuming an environment variable is set and in correct format can lead to all sorts of havoc. One solution to this common problem is to use some kind of validation tool (like my absolute favorite schema validation library Zod) to validate the process.env in your Lambda function and fail fast if the environment variables available at runtime don't match the expected ones. There are many good existing articles on how to do just that, but here's the gist of it:
Defining the schema for expected environment variables:
import z from "zod";const kebabCaseRegexp = /^[a-z0-9]+(-[a-z0-9]+)*$/;/** Schema for environment variables */export const EnvironmentSchema = z.object({ // You could just use single regexp, but having more explicit checks // results in more specific error messages if the input is invalid MY_ENV_VAR: z.string().startsWith("my-").min(8).regex(kebabCaseRegexp),});Using the schema, somewhere in your Lambda handler code...
import { EnvironmentSchema } from "./environment.ts";// This'll throw an Error if env doesn't match the schemaconst env = EnvironmentSchema.parse(process.env);export const handler = async () => { // Here you can safely access the environment variables console.log(env.MY_ENV_VAR);};Relatively straightforward. So then, why am I writing this blog post?
I've created an accompanying GitHub repository that provides relevant code examples.
Many of the articles about using Zod for environment variable validation focus only on the runtime code and reasonably so, as environment variables can be defined in bazillion different ways.
If you use AWS CDK, you're in control of defining the environment variables for Lambda functions in CDK code. There's primarily two ways to do that: Assigning environment property and using .addEnvironment(key, value) method. We'll focus on the former (environment) as it allows defining all environment variables in one place.
Zod provides type inferences utility z.infer – an alias to z.output which I prefer as it's more clear – to infer the output type of a schema (i.e. the type that represents the valid values):
import z from "zod";const kebabCaseRegexp = /^[a-z0-9]+(-[a-z0-9]+)*$/;/** Schema for environment variables */export const EnvironmentSchema = z.object({ MY_ENV_VAR: z.string().startsWith("my-").min(8).regex(kebabCaseRegexp),});/** Type representing the valid values of the schema: Used in Lambda. */export type Environment = z.output<typeof EnvironmentSchema>;This output type is useful within the Lambda runtime code, but if there's any transformations or other complex logic in the schema, then this won't help with CDK.
Luckily Zod type inference provides also (maybe the lesser known) z.input utility type:
import z from "zod";const kebabCaseRegexp = /^[a-z0-9]+(-[a-z0-9]+)*$/;/** Schema for environment variables */export const EnvironmentSchema = z.object({ MY_ENV_VAR: z.string().startsWith("my-").min(8).regex(kebabCaseRegexp),});/** Type representing the valid values of the schema: Used in Lambda. */export type Environment = z.output<typeof EnvironmentSchema>;/** Type representing the input values of the schema: Used in CDK. */export type EnvironmentInput = z.input<typeof EnvironmentSchema>;Utilize the input type somewhere in your CDK stack...
import { EnvironmentInput } from '../lambda/environment.ts';const environment: EnvironmentInput = { MY_ENV_VAR: 'my-awesome-value',};const fn = new NodejsFunction(this, 'MyFunction', { environment,});✨ Now if you change the schema in environment.ts, the TypeScript compiler will complain if you don't update the CDK code accordingly and vice versa! ✨
Looking at the the code examples above, the benefits might not be immediately clear, but take a look at the screenshots below:


It's not far fetched to imagine that you might want to go so far as to provide a full-blown "input zod schema" to be used with CDK: But remember that when you assign dynamic references (to other CDK created resources) as values to an environment variable – such as S3 bucket.bucketName etc – the value of that string will not be the final value, but instead a CDK Token that will be resolved later by CloudFormation. Hence the EnvironmentInput type (instead of full input schema) is the way to go.
We're not done yet: There's even more possibilities to go above and beyond with improving the Developer Experience! There's 3 scenarios that can be improved upon. The final schema with all the improvements is available at the end of this blog post.
In the Lambda function runtime code, once the environment variables are parsed and validated with EnvironmentSchema.parse(process.env), a developer shouldn't be able to modify the parsed value in anyway as this is often the semantic nature of environment variables: They are set (and read) when the process starts. Effectively, they should be immutable, i.e. unable to be changed without exception. Preventing (accidental) environment variable modification can remove a whole class of weird bugs and make the code more predictable – defensive programming of sorts, if you will.
This can be achieved by using Zod's readonly() method that wraps the inferred TypeScript output type with ReadOnly<T> and during JavaScript runtime calls Object.freeze:
export const EnvironmentSchema = z.object({ MY_ENV_VAR: z.string().startsWith("my-").min(8).regex(kebabCaseRegexp),}).readonly();As environment variable configuration often evolves over time and Zod is very flexible tool, even I myself have accidentally defined an EnvironmentSchema that can take other types than strings (z.string()) as input. This can lead to all sorts of problems, as the reality is that environment variables are always string values.
Of course, in the end the CDK environment-property on Lambda function construct props enforces Record<string, string> contract, but it would be nice to catch these issues right at the source – in the schema definition!
We can use a custom Zod type with TypeScript satisfies utility type to ensure that the input schema shape is always Record<string, string>, preventing a boatload of accidental errors:
/** Type utility to allow checking if input satisfies a type */type ZodInputFor<InputType> = z.ZodType<unknown, z.ZodTypeDef, InputType>;/** Schema for environment variables */export const EnvironmentSchema = z.object({ MY_ENV_VAR: z.string().startsWith("my-").min(8).regex(kebabCaseRegexp),}).readonly() satisfies ZodInputFor<Record<string, string>>;Now assigning any other than z.string() (or string-like) input properties will cause TypeScript compiler to error. Of course you can still use Zod's transforms to post-process the values as much as you want, as they affect only the output type, not the input.
Given that we can't use full-blown Zod schema for validating the environment variables as we define them in CDK due to concept of CDK Tokens, we have to use the EnvironmentInput type which provides the information about required string value properties.
This still leaves a lot to be desired in terms of guiding the (CDK) developer on what the expected values should be. Luckily we still have one trick up our sleeve: JSDoc comments! As TypeScript types can be annotated with JSDoc comments, we can provide additional information about the expected values:
/** * Magic variable. * - Must be in `kebab-case`. * - Must be prefixed with `my-`. * * It's used to do stuff. * Some additional guidelines and documentation for the variable. * Write whatever you want here. Even [links](https://example.com). * * @example "my-fantastic-value" */MY_ENV_VAR: z.string().startsWith("my-").min(8).regex(kebabCaseRegexp),The beauty of this is that any editor capable of speaking LSP (such as VS Code) can show this documentation when hovering over the EnvironmentInput type in the CDK code. This can be especially useful when you're working in a team and want to provide more context about the expected values:

Beware that code comments don't have any technical contract with the actual implementation and so they can be misleading if not kept up-to-date!
Some say: “No comments is better than incorrect comments”.
You might even spot several potential problems that might occur in the above example (done on purpose)!
It's up to you to find the right balance!
import z from "zod";const kebabCaseRegexp = /^[a-z0-9]+(-[a-z0-9]+)*$/;/** Type utility to allow checking if input satisfies a type */type ZodInputFor<T> = z.ZodType<unknown, z.ZodTypeDef, T>;/** Schema for environment variables */export const EnvironmentSchema = z.object({ /** * Magic variable. * - Must be in `kebab-case`. * - Must be prefixed with `my-`. * * It's used to do stuff. * Some additional guidelines and documentation for the variable. * Write whatever you want here. Even [links](https://example.com). * * @example "my-fantastic-value" */ MY_ENV_VAR: z.string().startsWith("my-").min(8).regex(kebabCaseRegexp),}).readonly() satisfies ZodInputFor<Record<string, string>>;/** Type representing the valid values of the schema: Used in Lambda. */export type Environment = z.infer<typeof EnvironmentSchema>;/** Type representing the input values of the schema: Used in CDK. */export type EnvironmentInput = z.input<typeof EnvironmentSchema>;When using more of serverless technologies and managed services (such as EventBridge, StepFunctions, et al) it leads to a situation of essentially having – at least parts of – “business logic in the infrastructure”; Or as Gregor Hohpe put it: “application architecture as code”.
Going down that road, you really ought to ensure that the contracts between the infrastructure code and the runtime code are well defined and enforced. Which improves the maintainability of your codebase and reduces the times you have to debug why your product is not working, just to find out that you forgot to update the environment variable somewhere!
Furthermore, by utilizing all the capabilities Zod, TypeScript and JSDoc provide, you can proactively prevent many errors and provide contextual information to your fellow team members – or even to your future self!