Go to the homepage

Safe environment variables in JavaScript

by Tim Severien

Who hasn’t accidentally deployed code that depended on a secret or environment variable that wasn’t configured for that environment? I know I have.

Environment variables are often critical for software to work. Sometimes in obvious ways, like making sure the browser is sent to the correct authentication URL, or that the right URLs are set in the Content Security Policy header. Sometimes, they’re not so obvious. Maybe nothing gets sent to our analytics service because we’re missing an ID. Maybe a map widget, buried in a multi-page form, isn’t loading because we’re missing a key. Perhaps everything is fine except for completing the purchase because we forgot the production token for our payment provider. Whoops!

Stupidly enough, many environments (like Node.js) don’t offer built-in tools to register, validate, and parse environment variables. Consequently, few codebases do so and most only have a reference to these variables when needed.

In short, missing variables can cause runtime issues from critical to negligible, and from obvious to invisible. That’s an incredible amount of uncertainty that many teams blatantly accept.

We can make these issues more visible with very little effort. I often introduce an env.ts file to my JavaScript codebases. It’s a file where we validate and parse all environment variables with Zod and export the parsed object, giving us many benefits:

  • When a variable is missing or invalid, we get early feedback
  • Environment variable validation is centralised
  • With Zod, we can add additional constraints
  • We get proper types throughout the codebase

Here’s an example of an env.ts file:

import { z } from 'zod';

const envSchema = z.object({
	ANALYTICS_ID: z.string().nonempty(),
	ANALYTICS_URL: z.httpUrl(),
	API_URL: z.httpUrl(),
	APP_PORT: z.coerce.number().int().positive().default(4321),
	MAP_TOKEN: z.string().nonempty(),
});

export const env = envSchema.parse(process.env);

We can import this file and use any variable with confidence and without last-minute validation:

import { env } from '@/env';

// ...

// env.APP_PORT is a number; we can safely increment it
server.listen(env.APP_PORT);

To ensure everything is validated and parsed at startup, we can add a side-effect import declaration in the application’s entry file (e.g. index.ts):

import '@/env';

If we want, we can ensure no process.env is being used outside of env.ts with a small script that searches the codebase using ripgrep:

#!/bin/sh
query='process.env'
exclude='!src/env.ts'

if [[ $(rg $query -g $exclude --type js --type ts ) ]]; then
	echo "process.env is being used outside src/env.ts. Fix it!"
	exit 1
fi

And just like that, no more runtime errors due to missing environment variables. The solution does have some caveats, though.

Depending on the environment we’re targeting, things can get finicky. Browsers don’t have access to environment variables, so many bundlers replace lookups with their actual values at build time. For example, process.env.NODE_ENV is often replaced with "production" for browser bundles. For these, we’ll have to explicitly add these lookups so the bundler knows what to include. Depending on volume and complexity, I sometimes opt for a dedicated env.browser.ts.

// Explicitly add variables so bundlers can replace values
export const env = browserEnvSchema.parse({
	NODE_ENV: process.env.NODE_ENV,
	PUBLIC_ANALYTICS_ID: process.env.PUBLIC_ANALYTICS_ID,
});

Take into account that we want to use flags over optional variables. If we only want analytics on production, and choose to make ANALYTICS_ID optional, we lose the early warning sign when ANALYTICS_ID is missing unintentionally. Introducing a flag (e.g. ANALYTICS_ENABLED) ensures that this cannot happen, even if it means ANALYTICS_ID is unused and may have a dummy value.

Overall, these caveats are worth it. Registering variables is a minor effort that pays itself back tenfold by turning silent failures into loud, early errors.