• Home
  • Blog
  • Best Practices for Handling Per-Environment Configuration in Your JS/TS Applications

Best Practices for Handling Per-Environment Configuration in Your JS/TS Applications

This article was published on Mar 23, 2023, and takes approximately 16 minutes to read.

When creating any type of application, it's really common the need a set of configurations.

By configuration, we can understand any settings or parameters that determine how the system behaves or operates. This can include things like API tokens or URLs and runtime information such as environment names.

The power of having configuration is that often, it's managed through a config file or other tools that allow us to modify the application behavior without having to modify the underlying code.

In this post, I want to show you a strong pattern that will make your application config easier to manage and less error-prone based on a methodology called the "12-factor app".

Envrionment Variables

A common way of defining configuration is via environment variables (as known as env vars).

Env vars can be defined in various ways. It can be via a file called .env:

.env
APP_ENV=development

... or declaring it in your command to execute the app:

APP_ENV=development node index.js

... or in case you're running your app in a higher level platform (such as Vercel, Netlify, or a custom platform), you'll define those in a configuration page, and the runner will inject those values globally for you.

After having those values defined, we can consume this variable via process.env:

index.js
if(process.env.APP_ENV === 'development'){
  // do something
}

Having env vars help us in this configuration task, but it has some flaws:

It might be undefined

Although we can consume those env vars directly from process.env, there's no guarantee that this value will be present:

index.js
console.log(process.env.SOME_VARIABLE); // undefined

And because we can't predict whether the variable is there, we can introduce bugs in our application because we thought the value was filled, but for some reason, it wasn't.

Maybe you forgot to add this variable. Or someone accidentally removed it from the configuration. Or your .env file is not being loaded properly. Many things can happen, and we only will know when we try to read these values.

Isn't type-safe

If you're using TypeScript, any attribute inside process.env will be considered a string or undefined by default.

That's because, in the node types (@types/node), the env inside the process is defined as a dictionary where the key is a string and the value is a union string | undefined.

TS will not complain that the attribute you're trying to check isn't there, but it won't give us a hint about the variable in the node environment.

To solve this, we could define anywhere in our codebase (or in a specific .d.ts file) an extension to the ProcessEnv interface from NodeJS:

environment.d.ts
declare namespace NodeJS {
  interface ProcessEnv {
    ENVIRONMENT?: string;
  }
}

By doing this, TS will be aware the ENVIRONMENT key is present inside process.env.

If you look closely, you'll see I've specified it as optional. This is because it's the most accurate way of representing it.

There's no way we can ensure that ENVIRONMENT is indeed defined, unless we do somewhere in the highest level of our app code a validation like this:

if (typeof process.env.ENVIRONMENT !== "string") {
  throw new Error("ENVIRONMENT is not defined");
}

declare namespace NodeJS {
  interface ProcessEnv {
    ENVIRONMENT: string;
  }
}

And now, because we validate it in runtime, we can ensure that ENVIRONMENT is indeed defined.

It's hard to have fallback values

It's also common to have a .env.example file where we describe all env vars we our application needs with some default values:

.env.example
# App port. It can be either 8080 or 3000
PORT=3000

# Current environment. It can be `local`, `staging`, `production`
APP_ENV=local

That's nice because once a new joiner starts working on the project, they will know they need to copy that .env.example as .env and run the application with the correct config.

Though, if we're building our application to run inside a Docker container, for example, this .env might not be there, and we have to manually describe somewhere (maybe in the docker-compose file) all variables and their fallbacks.

At this point, you might have been asking yourself:

"OMG... ok, is there a better way?"

That's a good question, and my answer is: yes, there's a better and safer way to deal with those env vars.

My proposal here isn't moving away from env vars but providing a mechanism where we can centralize all of them in a config file where we validate the values, fallback to some value if needed, and allow those values to be used in our application from this central place.

But before, I want to explore the importance of "config per environment".

Different environments and NODE_ENV

When I started learning to use NodeJS as a development platform, I struggled to understand how the frameworks tackle the famous NODE_ENV env var.

I mean, it's a magic value defined by someone, where it contains in "which mode" our application is running.

One mistake I made a couple of times was to consider NODE_ENV to determine the behavior of my application, like "if it's production, then this code should not run".

Often, the frameworks we run define the NODE_ENV to hold only 3 possible values:

  • production: when our application is running in "production mode" or fully optimized with all files compiled;
  • development (or dev in some cases): when our application is running in "development mode" and not all files are compiled yet, the assets are not optimized and often rely on a "watch mode";
  • test: often injected by testing frameworks and indicates that we're running tests only. No need for a watch mode, for example.

My first guess was:

Hmm... maybe if I'm running locally, then it's development for sure. Any other case will be production.

I remember I wrote a piece of code that could be only in our development environment, so I did something like this:

if(process.env.NODE_ENV === 'development'){
  // do something that only can be done in the development environment
}

When I deployed this code and checked the "dev" environment, my code wasn't being executed, and I couldn't understand why.

My mental model was incorrect, and I spent quite some time understanding where the problem was.

Luckily my teammate had this concept clear and taught me that no matter which environment we were running remotely, NODE_ENV would always be "production" because we were always running the app in production mode.

To achieve what I wanted, I'd needed another env var. One that we define ourselves the value.

APP_ENV

After my struggle with NODE_ENV, it was clear I needed something else. Something to better represent the possible environments I had to deal with. So I came up with the APP_ENV env var.

Differently from NODE_ENV, this variable will indeed represent all environments that I had, which back then were:

  • local: for local development and testing;
  • staging: we were using vercel, so we had the "preview URLs," which connected our application to a staging backend, and we could test things before it went live;
  • production: the final stage of our application. The one people were actually using it.

All I needed to do was define APP_ENV on Vercel and add it to the local .env file so it could be injected while I was coding.

With this change, my previous code was finally reflecting the reality of my app:

if(process.env.APP_ENV !== 'production'){
  // do something that cannot be done in production
}

Using per-env configuration

Now we have the use case clearly, let's imagine one scenario where each environment will consume data from a different API endpoint.

We could define a code like this:

function getEndpointUrl() {
  if (process.env.APP_ENV === "production") {
    return "https://company.backend.tech/api/v1";
  }

  if (process.env.APP_ENV === "staging") {
    return "https://staging.company.backend.tech/api/v1";
  }

  /**
   * For local development, we're running the backend locally.
   */
  return "http://localhost:4003/api/v1";
}

This code would work fine, but it doesn't scalle well. That's because it solves just one "configuration for X environment".

Here we have this custom function to determine the Endpoint URL. Still, we could have more data that relies on the same if/else, like having feature flags per environment, enabling mocks when it's development, having different tokens for securing an auth cookien section, and many other configurations that will be different per environment.

Making conditional statements for every single config would be tedious and hard to maintain and that's why I'm here to show you how to do this better.

The config strategy

The strategy I'm about to demonstrate is based on a handbook from Adam Wiggins called "The Twelve-Factor App".

This methodology is a set of best practices encompassing proven patterns and strategies for developing a robust and scalable codebase. It provides a set of principles that aid in creating SaaS applications while also being applicable to other types of software development. By adhering to the 12-factor methodology, developers can ensure their code is maintainable, portable, and resilient in the face of changing requirements and environments.

Getting started

For this demo, I will not use any framework. Instead, I will use a few files in TypeScript to simulate a Node app and execute the entry point with TSX.

Here's the repository you can see the full demo

Still, the concept could be applied to any type of project that uses NodeJS as a development platform, such as Nuxt, Remix, Sveltekit, Express, Angular, etc.

To start, we're going to create the following files:

.
โ””โ”€โ”€ src
    โ””โ”€โ”€ config
        โ”œโ”€โ”€ defineConfig.ts
        โ”œโ”€โ”€ envs
        โ”‚   โ”œโ”€โ”€ local.ts
        โ”‚   โ”œโ”€โ”€ prod.ts
        โ”‚   โ””โ”€โ”€ staging.ts
        โ”œโ”€โ”€ index.ts
        โ””โ”€โ”€ type.ts

Where:

  • config folder will hold all files related to that pattern;
  • defineConfig.ts will hold an abstraction to make easier the creation of configs (more on that matter later);
  • envs folder will contain the environments we want to support. You could leave those files flat in the config folder, but personally, I like to organize files in folders;
  • index.ts will export the config itself;
  • types.ts will hold the AppConfig types to be imported anywhere without creating any circular dependency.

Creating the AppConfig type

To start minimally, let's define only the apiURL where we can fetch data from:

config/types.ts
export type AppConfig = {
  apiURL: string;
};

Creating the defineConfig helper

You may have noticed that lately, many modern libs and tools that require some configuration have started providing a function called defineConfig (or createConfig).

The idea here is to hint at every property the configuration expects for JS and TS usage, increasing the Developer Experience (DX).

An example of how frustrating it is to configure something without hints is ESLint. They expect us to return an object with all configs in place:

.eslintrc.js
module.exports = {
  // config
}

But you have no clue about the properties it accepts. You have to access their website, go to the config page, and see what you need to configure, or you could use a special JSDocs @type syntax that will give you the auto-completion:

.eslintrc.js
/** @type {import('eslint').ESLint.ConfigData} */
module.exports = {
  // config
};

The thing is, if ESLint provided a function that accepts one argument (the configuration), we would have a better DX and won't need special syntax or reach documentation to know what it expects.

Enough behind the scenes; let's implement our defineConfig function:

config/defineConfig.ts
import { AppConfig } from "./type";

export function defineConfig(config: AppConfig) {
  return config
}

Simple right? Later we will support optional properties, and it'll become even handier.

Defining the environments

Now, let's define all 3 environments.

configs/envs/local.ts
import { defineConfig } from "../defineConfig";

export function createLocalConfig() {
  return defineConfig({
    apiURL: "http://localhost:4000",
  });
}
configs/envs/prod.ts
import { defineConfig } from "../defineConfig";

export function createProdConfig() {
  return defineConfig({
    apiURL: "https://api.mydomain.com",
  });
}
configs/envs/staging.ts
import { defineConfig } from "../defineConfig";

export function createStagingConfig() {
  return defineConfig({
    apiURL: "https://api.staging.mydomain.com",
  });
}

They are pretty simple until now, but we'll add more properties later.

The entry point of the config module

Finally, let's add the entry point of our config module, the one that will be imported whenever we want to check for an app config:

config/index.ts
import { createLocalConfig } from "./envs/local";
import { createProdConfig } from "./envs/prod";
import { createStagingConfig } from "./envs/staging";

export const appConfig = getConfig();

function getConfig() {
  switch (process.env.APP_ENV) {
    case "production":
      return createProdConfig();
    case "staging":
      return createStagingConfig();
    case "local":
      return createLocalConfig();
    default:
      throw new Error(`Invalid APP_ENV "${process.env.APP_ENV}"`);
  }
}

As you can see, based on the APP_ENV env var, we return the corresponding configuration for that environment.

"Raul, why the heck we're using functions 'createXConfig' instead the object itself?"

That's an excellent question and it's based on a problem I had in the past.

The first time I implemented this pattern, I had the idea to return the config created from each environment.

For my local environment, I was doing some conditional to determine whether I'd enable my app mocks or not based on an env var.

The problem was that because I imported all env modules, the JS runtime executed all module's code in ALL environments. In other words, when I import the "env/local", JS executes everything, and because I was trying to check for things that didn't exist, I got an error on production.

To avoid using dynamic import and making this config module async, I've decided to encapsulate all the logic inside a function and let JS execute that function only when I needed it.

Usage

Time to consume it, right?

Suppose we have a model function responsible for getting the user's information. This function does a fetch to our API ENDPOINT, adds the URI, and returns the user data:

models/getUserInfo.ts
import { appConfig } from "../config";

type UserInfo = {
  id: string;
  name: string;
  email: string;
};

export async function getUserInfo(userId: string) {
  const response = await fetch(`${appConfig.apiURL}/user/${userId}`);

  if (response.ok) {
    const data = await response.json();

    return data as UserInfo;
  }

  console.error("FAIL_FETCH_USER", await response.text());
  throw new Error("Failed to fetch user info. Please check the logs");
}

Note that I didn't need to worry about if/else based on the environment. Everything is isolated and safe in a place that only provides the information my application needs.

Adding new properties

Now, we want to add a new property. For some reason, I think it makes sense also provides the environment as config.

Let's change our AppConfig type:

config/type.ts
export type AppConfig = {
  env: "local" | "staging" | "production";
  apiURL: string;
};

Automatically, the TypeScript compiler will start to complain about our env files, forcing us to revisit the config file every time we add a new prop and preventing us from forgetting to add it to an environment.

configs/envs/local.ts
import { defineConfig } from "../defineConfig";

export function createLocalConfig() {
  return defineConfig({
    env: "local",
    apiURL: "http://localhost:4000",
  });
}
configs/envs/prod.ts
import { defineConfig } from "../defineConfig";

export function createProdConfig() {
  return defineConfig({
    env: "production",
    apiURL: "https://api.mydomain.com",
  });
}
configs/envs/staging.ts
import { defineConfig } from "../defineConfig";

export function createStagingConfig() {
  return defineConfig({
    env: "staging",
    apiURL: "https://api.staging.mydomain.com",
  });
}

Default values

Not all values need to be defined every time, right? There are some configs we want to have a default value and allow a single environment overrides it.

But here we have a problem.

We need to add this new (required) property to the AppConfig type but make the configuration argument from defineConfig flexible enough not to enforce those properties. We're talking about creating a more flexible sub-type from AppConfig.

There's no easy way of doing this in TypeScript without a type-utility package but let's try to make it minimal:

config/defineConfig.ts
import { AppConfig } from "./type";

type KeysWithFallbackValue = "mocksEnabled";

type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>;

type RequiredConfig = Optional<AppConfig, KeysWithFallbackValue>;

const defaultConfig: Pick<AppConfig, KeysWithFallbackValue> = {
  mocksEnabled: false,
};

export function defineConfig(config: RequiredConfig): AppConfig {
  return {
    ...defaultConfig,
    ...config,
  };
}

The code now seems slightly more complicated due to the types, so let's break it down:

  1. KeysWithFallbackValue is all the AppConfig keys that would have a fallback value.
  2. Optional is a generic custom utility that will make a few required properties in an object, required. This utility might be handy in other cases, so you can move to a global utility level if you want to;
  3. RequiredConfig is the AppConfig that the user MUST pass. Note that we use the Optional type utility to make mocksEnabled optional when creating a config, but it WILL BE present in the AppConfig itself;
  4. defaultConfig is all AppConfig values we want to fallback;
  5. defineConfig param changed to RequiredConfig. This will prevent every environment from having to define mocksEnabled;
  6. Now we return a new object that composes from the default config and overrides everything with whatever it's passed into the config.

Because we have made this change, TypeScript won't complain about a "missing property" when we import appConfig to use in a place "mocksEnabled" will be defined.

Now, we can go only in the local environment and set it to true:

config/envs/local.ts
import { defineConfig } from "../defineConfig";

export function createLocalConfig() {
  return defineConfig({
    env: "local",
    apiURL: "http://localhost:4000",
    mocksEnabled: process.env.ENABLE_MOCKS === 'true',
  });
}

And that's it. Our appConfig strategy is fully functional and ready to be used.

... but... I want to give a step further and enhance it even more.

Enhacing the config system with Zod

A brief intro to Zod

In case you don't know zod, it's a lib that, at first glance, it looks like just another validation library, but its simplicity hides its true power.

Imagine you're consuming a JSON API. How do you ensure the API is returning the data you expect?

Well, you could create a function that receives the body of that response and check if all fields are there, correct?

Indeed, that's one way, but how tedious would that be? What if the payload is HUGE? What about the other endpoints? Are you going to write all over this validation?

I've been using it for a while, and in my honest opinion, I think zod is one of the best JS libraries that fit any type of application.

If you're validating a form, consuming APIs, creating CLIs, or scripts, it doesn't matter. You ALWAYS need to validate input, and zod does this masterfully.

As a bonus, Zod provides fantastic support to TypeScript, so we have validations on both run time (JS) and compile time (TS).

Defining our schema

While the code will change a bit, the folder structure will remain the same.

In our types file, let's import zod and define our AppConfig schema there:

config/types.ts
// export type AppConfig = {
//   env: "local" | "staging" | "production";
//   apiURL: string;
//   mocksEnabled: boolean;
// };

import { z } from "zod";

export const appConfigSchema = z.object({
  env: z.enum(["local", "staging", "production"]),
  apiURL: z.string(),
  mocksEnabled: z.boolean().default(false),
});

export type AppConfig = z.infer<typeof appConfigSchema>;

I suppose only looking at the snippet, you can guess we have the same API as before, right?

The difference is that now we have defined a schema we'll use later to validate the user's config, and we generate the AppConfig type by deriving from the schema (zod +1).

Also, another plus is that we don't need to define the default values in another place, which means we can move the "PartialAppConfig" from defineConfig to this file:

config/types.ts
// export type AppConfig = {
//   env: "local" | "staging" | "production";
//   apiURL: string;
//   mocksEnabled: boolean;
// };

import { z } from "zod";

export const appConfigSchema = z.object({
  env: z.enum(["local", "staging", "production"]),
  apiURL: z.string(),
  mocksEnabled: z.boolean().default(false),
});

export type AppConfig = z.infer<typeof appConfigSchema>;

export type RequiredConfig = Optional<AppConfig, KeysWithFallbackValue>;

type KeysWithFallbackValue = "mocksEnabled";

type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>;

Changing the defineConfig function

Now, we will remove almost everything we've done before to use the zods method "parse".

config/defineConfig.ts
// import { AppConfig } from "./types";

// type KeysWithFallbackValue = "mocksEnabled";

// type Optional<T, K extends keyof T> = Pick<Partial<T>, K> & Omit<T, K>;

// type RequiredConfig = Optional<AppConfig, KeysWithFallbackValue>;

// const defaultConfig: Pick<AppConfig, KeysWithFallbackValue> = {
//   mocksEnabled: false,
// };

// export function defineConfig(config: RequiredConfig): AppConfig {
//   return {
//     ...defaultConfig,
//     ...config,
//   };
// }

import { type RequiredConfig, appConfigSchema } from "./types";

export function defineConfig(config: RequiredConfig) {
  return appConfigSchema.parse(config);
}
We still have to make the config we receive as an argument less strict.

And that's it. That's the only change we need to make to use Zod and have some protection in compile and runtime.

Final advice and bonus tips

At this point, I hope it's clear why you should go for a config strategy instead acceof ssing process.env or doing if/else everywhere in your code.

Now I want to give some extra tips to make the day-to-day code effortless.

ESLint "no-process-env" rule

There's one rule from ESLint called "no-process-env".

This rule will prevent people from accessing process.env directly in favor of a config.

This rule is handy when you're working with more people.

Be careful with nested object config

You might find the case has nested config levels that may or may not be optional, and that's fine.

The only thing you have to be aware of is that using zod, you might provide a "default" value as an empty object and, inside that object schema, also specify each property default value:

export const appConfigSchema = z.object({
  env: z.enum(["local", "staging", "production"]),
  apiURL: z.string(),
  mocksEnabled: z.boolean().default(false),
  featureFlags: z
    .object({
      feature1: z.boolean().default(false),
    })
    .default({}),
});

In this case, for example, if we try to parse an object that does not define featureFlags, it won't throw an error because we're falling back to an empty object.

Don't use the config directly in client-side code

As you might have noticed, we rely on process.env to determine which configuration to load. That means your application will break if you consume this configuration into a client-side code.

If you need to use it in the client, if you're using a Server Side Rendering (SSR) frame,work tries to find how to pass properties from Server to Client.

If you're using SPA or any Static generation, you might need to load this configuration in your bundler config and find a way to inject those values like Vite's HTML Env Replacement.

Conclusion

I hope this post becomes handy and helps you make your codebase even more solid.

If you have any comments, feel free to reach out via Twitter.

Ciao! ๐Ÿ‘‹

References