Type-safe environment variables in Node.js

I first started centralising my Node.js project environment variables whilst striving for greater TypeScript type safety. Because process.env variables are of type string | undefined by default, you have to use one of a number of workarounds to avoid issues similar to the following:

doSomething(process.env.MY_ENVIRONMENT_VARIABLE)
// Argument of type 'string | undefined' is not assignable to parameter of type 'string'.

Some of the simplest workarounds are:

/**
* This is not good, completely disables all type-checking. What if we had more
* arguments to pass to `doSomething`?
*/

// @ts-ignore
doSomething(process.env.MY_ENVIRONMENT_VARIABLE)

/**
* This isn't ideal because the environment variable could actually be
* undefined. We wouldn't become aware of this problem until the application
* threw an error or acted in an unintended way.
*/

doSomething(process.env.MY_ENVIRONMENT_VARIABLE as string)

/**
* This is better but what if we use `MY_ENVIRONMENT_VARIABLE` in a lot of
* different places? Ideally we would avoid using this conditional everywhere.
* Also, how do we let the developer know that a required environment variable
* is missing? This will silently fail.
*/

if (process.env.MY_ENVIRONMENT_VARIABLE) {
doSomething(process.env.MY_ENVIRONMENT_VARIABLE)
}

This isn't only something to be concerned about if you're using TypeScript. Your application will successfully start and, likely, exhibit strange behaviour if it is missing required environment variables.

One method to solve the above issues is to pull all environment variable related configuration into a single module.

Something like the following would solve the above issues:

// environment.ts
if (!process.env.MY_ENVIRONMENT_VARIABLE) {
throw new Error('process.env.MY_ENVIRONMENT_VARIABLE is missing')
}

export const MY_ENVIRONMENT_VARIABLE = process.env.MY_ENVIRONMENT_VARIABLE

// usage
import { MY_ENVIRONMENT_VARIABLE } from './config'

/**
* This works because the environment variable can now only be of type `string`!
*/

doSomething(MY_ENVIRONMENT_VARIABLE)

Whilst the above is a viable solution it certainly isn't the most elegant. The following is what I have settled for in my own projects:

/**
* zod is an absolutely _fantastic_ schema declaration and validation library.
*/

import { z } from 'zod'

const envVarSchema = z.object({
MY_ENVIRONMENT_VARIABLE: z.string(),
ANOTHER_ONE: z.string().default('SOME_DEFAULT_VALUE'),
DATABASE_PASSWORD: z.string(),
})

export const env = envVarSchema.parse(process.env)

An example usage of the above looks as follows:

import { env } from './environment'

// No type errors! Every key on `env` has a value of `string`!
doSomething(env.MY_ENVIRONMENT_VARIABLE)

The above solution will work without zod. Any data validation library such as superstruct, joi or yup will do the trick!

You can now safely use environment variables in your project without having to worry about TypeScript errors or strange bugs occurring.