Bulletproof Your Node.js Backend: Manage Environment Variables with Confidence
Tags: node, javascript, typescript, webdev
Published: 2023-04-15 11:00:02

## Pretext

Managing environment variables is a crucial aspect of building robust and secure Node.js applications. These variables contain sensitive information such as API keys, database credentials, and other configuration details that your application needs to function properly. However, managing environment variables can quickly become a headache, especially when dealing with multiple environments, team members, and deployment scenarios.

One of the most significant pain points of environment variable management is the lack of type safety. Without type safety, it's easy to introduce bugs and security vulnerabilities, as developers may accidentally use the wrong variable or misspell its name. Additionally, environment variables can be difficult to keep track of, as they're often scattered across different files and deployment environments.

Fortunately, there are solutions to these problems. In this blog post, we'll explore how to manage environment variables in a type-safe manner using TypeScript. We'll look at why type safety is essential, how it can help prevent bugs and security issues, and best practices for implementing it in your Node.js backend. By the end of this post, you'll have a better understanding of how to manage your environment variables with confidence and peace of mind.

## The Problem
Using [dotenv](https://www.npmjs.com/package/dotenv) for environment variable management doesn't allow breaking up the environment variables into different files like `.env`, `.env.development`, and `.env.production`, among others. Additionally, it doesn't allow for variable expansion. For instance, when using [dotenv](https://www.npmjs.com/package/dotenv), the following code will not work:
```
APP_NAME="My app name"
APP_DESCRIPTION="$APP_NAME is awesome"
```
Now, I expect `APP_DESCRIPTION` to be `"My app name is awesome"` but it won't be because [dotenv](https://www.npmjs.com/package/dotenv) doesn't perform variable expansion by itself, instead I'd have to use another library [dotenv-expand](https://www.npmjs.com/package/dotenv-expand) to achieve this.

Also, I like using [typescript](https://www.typescriptlang.org/) and using `process.env` doesn't really give me any clue about my environment variables.

This approach works but has following drawbacks:
1. No IDE support for `process.env.APP_NAME`
2. No auto suggestion for `process.env.*` variables.
3. No Type checking is available for `process.env.APP_NAME`, and writing custom type is a hassle, because every time you need to add or remove an environment variables you'd have to update it's types as well.
4. No validation is performed on the values of these environment variables.


Typically, you should structure your `.env` files like the following:
> `.env`
> `.env.development`
> `.env.test`
> `.env.production`

Optionally:
> `.env.development.local`
> `.env.test.local`

where `.env` contains those environment variables that are common across all environments like:
1. `APP_NAME`
2. `PORT`
3. `TZ`
etc.

And environment specific file like `.env.development` should contain environment specific variables like:
1. `NODE_ENV`
2. `DATABASE_URL`
3. `SECRET`
etc.


## The Solution
Now, to eliminate all the drawback mentioned above, like type safety, variable expansion, and validation. Follow the solution below

Now obviously we'll still be using the file structure described above to keep our environment variables split among environment specific files.

This can be achieved with [dotenv-cli](https://www.npmjs.com/package/dotenv-cli), a new fast-growing npm package that can be used to eliminate the need of [dotenv](https://www.npmjs.com/package/dotenv), [dotenv-expand](https://www.npmjs.com/package/dotenv-expand), and our custom code for loading multiple `.env.*` files.

### Syntax
```sh
npm i -D dotenv-cli
```

update your `package.json` file scripts to be:
```
"dev": "dotenv -c development -- <your command goes here ex: ts-node src/index.ts>",
"build": "tsc",
"start": "dotenv -c production -- node dist/index.js",
```

What we're doing in `dev` script is using [dotenv-cli](https://www.npmjs.com/package/dotenv-cli) with -c flag to load:
1. `.env`
2. `.env.development`
3. `.env.development.local` (if present)

In `start` script we're loading:
1. `.env`
2. `.env.production`

**TIP: Don't use `.env.production.local` file.**

So, This solves our multi-environment multi-file problem, and reduced our dependency from 2 package to one, and eliminate any custom code writing for loading `.env` files.

Now, How can we solve our IDE support, Type checking, and validation problem.
For that we'll use [envalid](https://www.npmjs.com/package/envalid), a fast growing npm package to validate and provide types for our environment variables.

To get started:
```sh
npm i envalid
```
And yes we need it as our production dependency.

Now, we'll create a file right beside our `index.ts` file named `env.ts`, with the following content:
```ts
import { cleanEnv, str, email, json, port } from 'envalid'

// INFO: using $env to differentiate it from process.env 
export const $env = cleanEnv(process.env, {
  PORT:               port(),
  API_KEY:            str(),
  ADMIN_EMAIL:        email({ default: 'admin@example.com' }),
  EMAIL_CONFIG_JSON:  json({ desc: 'Additional email parameters' }),
  NODE_ENV:           str({ choices: ['development', 'test', 'production', 'staging']}),
})

$env.isProduction    // true if NODE_ENV === 'production'
$env.isTest          // true if NODE_ENV === 'test'
$env.isDev           // true if NODE_ENV === 'development'
```

And use `$env` instead of `process.env`.
By using `$env` we'll get:
1. Auto-completion ex: `$env.` should list all possible values in your IDE.
2. Type checking ex: `$env.PORT` will be of number type.
3. IDE support like: Reference count, Jump to code, show definition etc.
4. Validation provided by [envalid](https://www.npmjs.com/package/envalid), as well as custom validation logic that we can combine with [envalid](https://www.npmjs.com/package/envalid), to really fine tune according to our requirement.
5. *BONUS* by using `env.ts` file we eliminate the requirement for a `.env.example` file that a lot of us use to keep track of all the environment variables names that is used all across the project.

## And there you have it
To learn more visit:
1. [envalid](https://www.npmjs.com/package/envalid)
2. [dotenv-cli](https://www.npmjs.com/package/dotenv-cli)

Feel free to comment down below, and let me know what you think.