Type-safe env variables
Tags: typescript, javascript, node, tutorial
Published: 2022-09-30 10:24:05

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 ](https://github.com/KATT/envsafe)and his [recent Twitter reply to my thread](https://twitter.com/alexdotjs/status/1571993436613660674), where he suggested handling environmental variables in Typescript with a generic schema validation library over a dedicated library. If you’re not following [Alex](https://twitter.com/alexdotjs) 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](https://github.com/trpc/examples-next-prisma-starter/blob/next/src/server/env.js) 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.

```typescript
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.

```typescript
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](https://github.com/trpc/examples-next-prisma-starter/blob/next/src/server/env.js).

### 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.

{% embed https://twitter.com/grabbou/status/1571991415370166273 %}

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](https://twitter.com/alexdotjs/status/1571993436613660674), I figured it would be great to write an equivalent of devDefault that works with it.

```typescript
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:

```typescript
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