Ari Palo

Typesafe Environment Variables via AWS CDK

Publish date
Reading time
10 min read
Tag aws
Tag cdk
Tag lambda
Tag typescript
Tag zod

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.

The Basics

Tip

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:

typescript
iac/function.ts

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',  },});
Info

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:

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:

typescript
lambda/environment.ts

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),});
typescript
lambda/handler.ts

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?

Type safety between CDK and Lambda

Info

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):

typescript
lambda/environment.ts
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:

typescript
lambda/environment.ts
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>;
typescript
iac/function.ts

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:


Missing Value
TypeScript compilation fails if you forget to set an environment variable in the CDK code (or define it with incorrect type).
Non-Existing Key
Additionally, trying to assign a non-existing key to environment variables will also cause TypeScript compilation to fail.
Warning CDK Tokens

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.

Improving the Developer Experience even more

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.

1. Immutable Environment Variables

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();

2. Prevent errors caused by schema modification

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.

3. Input type documentation

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:

JSDoc via LSP
Danger Comments are not code

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!

Final Schema

typescript
lambda/environment.ts
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>;

Conclusion

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!