Type-safe env variables with Typescript

Almost all our projects have environmental variables. We use them to configure various aspects of our systems. What if they’re missing? What if they’re misconfigured?

This is typically a challenge when working with TypeScript, which immediately warns you about missing properties as soon as you start accessing your variables from process.env.

A quick fix would be to declare types for process.env and call it a day. But can we do better?

Of course! 🎉

And the solution is not even TypeScript-specific at all. But its presence definitely prompts us to deal with that scenario sooner than later. Otherwise, the compiler isn’t happy!

High-level idea

Define a schema for your process.env object. Then, parse it and throw an error if validation wasn’t successful. Rather than accessing a raw process.env object, prefer to work with a parsed result.

It will hold type information, have an appropriate shape, and as a bonus point - let you transform its stringified value to a primitive that your application expects (e.g. PORT should really be a number!)

If that’s nothing new to you, you’re good to stop reading at this point! However, if you want to check the potential implementation and learn about handling default values in development mode, keep reading (and consider subscribing for further articles)


Disclaimer: This post is inspired by env-safe by @alexdotjs and his recent Twitter reply to my thread, where he suggested handling environmental variables in Typescript with a generic schema validation library over a dedicated library. If you’re not following Alex on Twitter yet, you should definitely do it now!

In this quick blog post, I will use one of TRPC's examples available on GitHub to explain this concept in real life and then show you how to handle default values in development mode.


Implementation

First, let’s define a schema to represent the type of environmental variables our application expects to work.

import { z } from "zod";

const schema = z.object({
  API_URL: z.string().url(),
  OAUTH_CLIENT: z.string(),
  OAUTH_SECRET: z.string(),
  PORT: z.string().transform(Number),
});

To do so, we will use z.object() and describe each property with an appropriate type. This will naturally look different if you already use a different schema library in your project.

Next, we will validate process.env the object against our schema.

const parsed = schema.safeParse(process.env);

if (!parsed.success) {
  console.error(
    "❌ Invalid environment variables:",
    JSON.stringify(parsed.error.format(), null, 4)
  );
  process.exit(1);
}

export const env = parsed.data;

If all environmental variables are present and of the correct type, the values will be accessible under parsed.data and include typings. Otherwise, an error will be thrown.

PS. You can find this code in one of TRPC's example projects, such as next-prisma-starter.

Defining default values in development mode

One of the great features of env-safe that I really fell in love with is devDefault. Just like the name suggests, it lets you define default values for your environmental variables while in development.

What’s super cool about this is that it lets you define and share some of the development settings with the rest of your team without the risk of using them in production. That’s definitely something that boolean conditionals don’t guarantee.

Unfortunately, there’s nothing similar built into Zod. That’s not a surprise. After all, it’s a generic schema validation library, not a specialized env tool.

Since the author of env-safe library commented on Twitter that he’s using zod nowadays too, I figured it would be great to write an equivalent of devDefault that works with it.

import { TypeOf, z } from "zod";

onst withDevDefault = <T extends z.ZodTypeAny>(
  schema: T,
  val: TypeOf<T>
) => (process.env["NODE_ENV"] !== "production" ? schema.default(val) : schema);

While the function looks pretty simple, it was fun to write and play around with Typescript generics to make it work. Thankfully, the Zod documentation is pretty helpful in writing functions that take schema as an argument.

Let’s take a look at what our schema could look like with default arguments applied:

import { z } from "zod";

const schema = z.object({
  API_URL: withDevDefault(z.string().url(), "localhost:8080"),
  PORT: withDevDefault(z.string(), "4000").transform(Number),
});

That’s pretty much it!

You can now safely access your project's environmental variables and ensure they’re set. If not, you’ll learn about that as soon as your process exits with a non-zero code.


In the upcoming series of articles, I’ll discuss deploying Node.js (and Typescript) projects to the AWS cloud with Docker and Terraform, where this approach to dealing with environmental variables will come in handy. If you’re interested in that topic, check in here from time to time!


Thank you for reading,
Mike

Subscribe to Mike Grabowski
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.