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);
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);
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;
Usage would look like this:
import * as config from './config'
const port: number = config.port;
// OR
import { environment } from './config'
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 todevelopment
. -
isDeployed: boolean
- derives from theenvironment
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
- anIORedis
configuration object based on environment variablesREDIS_HOST
andREDIS_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
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]
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 to
env-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)
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:
Etc.
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.
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 findconfig
.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.
You can do this with yargs, which is also capable of taking env vars from a variety of different sources.