DEV Community

Matthew Jacobs
Matthew Jacobs

Posted on • Originally published at codingmatty.com on

Type Strict Environment Variables

I love Typescript.

And Environment variables are one of the essential parts of storing run-time configuration securely.

But one of the most significant issues with using environment variables within a Typescript application is the lack of types.

Let's say you are trying to use the PORT environment variable, but you end up getting an error like this:

Because the default type for every environment variable is string | undefined, you end up having to validate them in creative ways. Here is one solution to the above problem:

const port: number = process.env.PORT ? parseInt(process.env.PORT) : 4000;

startServer(port);
Enter fullscreen mode Exit fullscreen mode

But how pretty does that look? 🤮

And this is just an example of one environment variable that needs to be validated. What about multiple variables? What about enumerations (i.e. development, production)? Using environment variables on their own is kind of a bane. So let's look at a better way.

I almost decided to build another library myself to provide a solution to this issue, but I decided to check out what's out there first.

Luckily, there are quite a few projects that have sought to solve this problem 😄

Solution

First, what would be an ideal solution?

  • Declarative-like. I initially thought a single yaml file to describe the environment would work, but I learned that it's not that easy to convert a yaml file to Typescript types without an extra step.
  • Useful errors. What environment variable(s) are not set up correctly and why? Is a variable not a number? Not the correct enumeration? Whatever the solution is should describe the problem well.
  • Ability to derive other configurations from environment variables. This one is more of a nice-to-have feature. I like to have booleans like isEmailEnabled to enable/disable emails from being sent when the API Key isn't provided instead of checking for the existence of the API Key directly.

I ended up deciding to use env-var.


env-var usage stats on NPM as of 10/22/22

It uses a fully type-safe builder pattern API which allows you to set the conditions by chaining functions together, making it relatively readable.

As a small example, this is how we can define the port variable in the example above:

const port: number = env.get('PORT').default(4000).asPortNumber();

startServer(port);
Enter fullscreen mode Exit fullscreen mode

This looks much nicer, albeit more verbose, but way more readable. The asPortNumber() is the function that triggers validation, but it also has the bonus of making sure the port number is valid, i.e. is it between 0 and 65535?

To encapsulate all environment variables into a single place and avoid piecemealed validations throughout the codebase, I created a config.ts file at the root of my source directory.

Below is a code snippet of my config file with most of my environment variables.

// config.ts
import env from 'env-var';

export const port = env.get('PORT').default(4000).asPortNumber();
export const environment = env
  .get('NODE_ENV')
  .default('development')
  .asEnum(['production', 'staging', 'test', 'development']);
export const isDeployed =
  environment === 'staging' || environment === 'production';

export const secret = env.get('SECRET').required().asString();
export const sentryDsn = env.get('SENTRY_DSN').asUrlString();

export const sendgridApiKey = env.get('SENDGRID_API_KEY').asString();
export const sendgridFromEmail = env
  .get('SENDGRID_FROM_EMAIL')
  .required(!!sendgridApiKey)
  .asString();
export const isEmailEnabled = sendgridApiKey && sendgridFromEmail;

export const redisHost = env.get('REDIS_HOST').asString();
export const redisPort = env.get('REDIS_PORT').asPortNumber();
export const redisConfig =
  redisHost && redisPort
    ? {
        host: redisHost,
        port: redisPort,
      }
    : undefined;
Enter fullscreen mode Exit fullscreen mode

Usage would look like this:

import * as config from './config'
const port: number = config.port;

// OR

import { environment } from './config'
Enter fullscreen mode Exit fullscreen mode

Now, I have limited the need to check for any environment variable to be undefined, and my types are strict. I am even able to derive some extra variables to make my code more readable, like how isDeployed is being derived from the value of environment.

Here is a quick explanation of some of the variables:

  • port: number - defaulted to 4000.
  • environment: EnvrionmentEnum - defaulted to development .
  • isDeployed: boolean - derives from the environment variable.
  • secret: string - will throw an error if not defined.
  • sentryDsn: string - will throw an error if it's not in URL format.
  • sendgridApiKey: string | undefined - optional API key for Sendgrid.
  • sendgridFromEmail: string | undefined - will throw an error if it's not defined but the SendGrid API Key is defined.
  • isEmailEnabled: boolean - derives from whether other Sendgrid variables are defined.
  • redisConfig - an IORedis configuration object based on environment variables REDIS_HOST and REDIS_PORT.

You can even do a quick check of a dotenv file (or check your system's configuration) via a command like:

# .env
PORT="xyz"

---

$ npx dotenv - .env -- ts-node config.ts

.../node_modules/env-var/lib/variable.js:47
    throw new EnvVarError(errMsg)
          ^
EnvVarError: env-var: "PORT" should be a valid integer
Enter fullscreen mode Exit fullscreen mode

OR

$ NODE_ENV="test" npx ts-node src/config.ts

.../node_modules/env-var/lib/variable.js:47
    throw new EnvVarError(errMsg)
          ^
EnvVarError: env-var: "NODE_ENV" should be one of [production, staging, test, development]
Enter fullscreen mode Exit fullscreen mode

This will help you confirm whether your .env file is set up correctly or tell you exactly what errors you have.

Alternatives

Disclaimer: I haven't tested any of these out, but I figured I wanted to mention them for completion.

  • tconf This library seemed like what I wanted to use, but it requires a lot of different components, including a type file, a yaml configuration file, and a file to glue all of those together. It might be good for larger projects with many variables, but it seemed overkill for my use case.
  • unified-env This library seems robust, allowing you to tie in variables from the environment as CLI arguments and from a .env file. It provides one file to parse all of the variables with validation options.
  • @velsa/ts-env`This library is similar toenv-var`, it just uses one function to parse an environment variable with options for validating the value. But it hasn't been updated in a while and doesn't look very widely used.
  • ts-app-env This library looks exactly like @velsa/ts-env, but maybe a little more up-to-date. The documentation is a little lacking, though.
  • ts-dotenv This library wraps the dotenv library with the ability to type information with a singular schema object. It seems excellent, but if you don't use .env files in production, it may add an unnecessary step.

I hope this post helps you find sanity in using environment variables in your Typescript application.

Let me know if you have any questions.

You can find me on Twitter or email me at codingmatty@gmail.com.

Top comments (5)

Collapse
 
webjose profile image
José Pablo Ramírez Vargas

It is funny how the entire world is so fixated into thinking environment variables are the only way to configure things.

Here's your best solution: wj-config. Not because I made it, but because it really provides so much flexibility on how to configure things in ways you won't find anywhere else.

You can:

  • Provide configuration data from different sources and formats.
  • Combine any and all configuration data into a single configuration object.
  • Yes, you can also include environment variables as a data source in the mix.
  • You can trace the values found in your configuration object down to the exact data source that provided it.
  • It creates URL's for you, automagically, from the data configured like host name, port number, scheme and paths. It will even replace route values, URL-encoded for you and will dynamically add query strings too.

Etc.

Collapse
 
codingmatty profile image
Matthew Jacobs

I don't believe environment variables are the only way to configure things. In fact there are a lot of times where you want configuration to be dynamic or configurable, which is where things like feature flags come in. But environment variables will always be important and thus should be considered.

wj-config looks interesting, and seems to have a lot of flexibility. It did not come up in my searches, but seems like a worthy option especially for large projects.

Collapse
 
webjose profile image
José Pablo Ramírez Vargas

I see, no worries. Is just that your alternative list literally has -env in all but one. For example, I find surprising that you did not find config.

Thread Thread
 
codingmatty profile image
Matthew Jacobs

Fair enough. My primary concern I was trying to solve here was adding strict types to environment variables, because if you import a json file with config variables everything is pretty much typed except for enumerations.

Collapse
 
brense profile image
Rense Bakker

You can do this with yargs, which is also capable of taking env vars from a variety of different sources.