Skip to content

Manage config from environment variables and sensible defaults, with typing and validation.

License

Notifications You must be signed in to change notification settings

alecperkins/defaulted

Repository files navigation

defaulted

npm package typescript MIT license test status

defaulted() constructs a configuration object from given default values and the process environment. It allows for defining sets of defaults for different capital-E ENVIRONMENTs. Keys and values are typed and validated. The config will fail loudly if you forget to define a value and try to use it, or set a defined value to the wrong type. The environment groups are useful for ensuring certain configs are always set a certain way, or must always be defined, for particular deployment contexts.

Installation

npm install defaulted

and include as a JavaScript or TypeScript module (types included):

import defaulted from "defaulted";

…or a CommonJS module:

const defaulted = require("defaulted");

Usage

Set up centralized config with defaults that may be overridden by environment variables. The defaults act like a schema. Only keys present in the defaults will be extracted from the environment, and they will be cast to a type matching the type of the default value.

const config = defaulted({
  DATABASE_URL: "postgres://localhost:5432/db",
  NEW_FEATURE_ENABLED: true,
  SERVICE_URL: "https://sandbox-api.example.com",
});

Read config values:

const client = new DBClient(config.DATABASE_URL);

if (config.NEW_FEATURE_ENABLED) {
  console.log("Enabled!");
} else {
  console.log("Not Enabled!");
}

const response = await fetch(config.SERVICE_URL);

Override values via env:

> NEW_FEATURE_ENABLED=false node main.js
Not Enabled!

Trying to access config keys not defined or set any will throw:

if (config.OTHER_FEATURE) { // <-- Throws!
config.NEW_FEATURE_ENABLED = false // <-- Throws!

Environment Overrides

defaulted() takes one or two arguments: the default values to use if the matching variables are not defined in the environment, and an optional mapping of overrides. These overrides will be selected based on value of the ENVIRONMENT variable.

For another example, given this setup:

const config = defaulted({
  DATABASE_URL: "postgres://localhost:5432/db",
  MOCK_EMAIL: true,
  SMTP_HOST: "smtp.example.com",
  SMTP_PORT: 587,
}, {
  prod: {
    DATABASE_URL: undefined,
    MOCK_EMAIL: false,
  },
  test: {
    MOCK_EMAIL: false,
    SMTP_HOST: "smtp-echo.local"
  },
});

If you do want to send email from your local machine, you can disable the mocking just for that "deployment" by starting the server with something like MOCK_EMAIL=false npm start. Conversely, a deployment with ENVIRONMENT=prod will not mock email by default unless overridden by the environment. The prod environment also requires the DATABASE_URL be defined, while local dev doesn't need any additional configuration out of the box.

Validation

defaulted attempts to be strict about the keys and values, to avoid issues from failing to define a necessary value or inconsistencies from modifying the values after boot.

The resulting config object will have explicit keys matching the default, to aide with type checking and autocomplete. Attempting to access a key that was not specified will throw. Those keys will also be read-only, and will likewise throw on assignment. (Doing either will also fail type checking.)

If the environment specifies a value that cannot be cast to the type, defaulted will throw. See below for specifics on acceptable values.

Empty strings

If the default type is a string, empty strings will be allowed in the environment. For numbers and booleans, empty strings present will cause defaulted to throw. These keys in the environment must be set to a non-empty value, or unset.

Numbers

Keys with number-type defaults must be numeric in the environment if present. Any value that results in NaN when converted will throw. Integers, floats, hexadecimal, binary, and exponential are allowed.

Note that process.env provides strings only, so the casting behavior of Number might not work as expected. Given VAL=true, Number(process.env.VAL) will produce NaN rather than 1 the way Number(true) does. For this reason, defaulted will throw instead.

Booleans

Booleans must be specified in the environment using the words "true" or "false", in any capitalization, or the numbers "1" or "0". Any other value will throw if the default is expecting a boolean. Note that, like with numbers, this different than Boolean() casting since process.env only provides strings.

defaulted.secrets()

defaulted works well for secrets and allows you to provide non-sensitive defaults locally, but set explicitly undefined values for production. This guards against deploying a new instance of an app without mandatory secret config. This is possible with the regular defaulted, but gets repetitive. The defaulted.secrets(…) function makes this easier by inverting the approach to merging the defaults.

const secrets = defaulted.secrets([
  "SESSION_SECRET",
  "SOME_API_KEY",
], {
  local: {
    SESSION_SECRET: "dev-session-secret",
  },
  test: {
    SESSION_SECRET: "test-session-secret",
    SOME_API_KEY: "mocked",
  },
});

When calling defaulted.secrets, specified keys do not have default values unless defined in the environment overrides, and are required to be provided by env vars. The function will throw if they are not found in process.env. This way, local development or tests can have out-of-the-box unsensitive defaults if available, but production must provide the secrets via env variables.

Like regular defaulted-based configs, accessing keys not specified in the initial list, trying to set a key, or trying to override an unspecified key will throw. These operations will also fail type checking but make sure the list of keys is marked as const for thorough strictness.

const secrets = defaulted.secrets([
  "SESSION_SECRET",
  "SOME_API_KEY",
] as const);

Note that secret values are passed through as strings. If for some reason you do need Number or Boolean secrets, you can use the regular defaulted call and set the env overrides to undefined to make them mandatory:

const secrets = defaulted({
  SECRET_NUMBER: 4,
}, {
  prod: {
    SECRET_NUMBER: undefined,
  },
});

The above will throw in ENVIRONMENT=prod if SECRET_NUMBER is not in the env.

FAQ & Best Practices

Secrets

A useful way to handle secrets is create an explicit secrets config. This separates secret values from the regular config so they are clearly sensitive and it's easy to trace which code uses them.

const config = defaulted({
  SOME_API_URL: "https://sandbox-api.example.com",
}, {
  prod: {
    SOME_API_URL: "https://api.example.com",
  },
});

const secrets = defaulted.secrets([
  "DATABASE_URL",
  "SOME_API_KEY",
], {
  local: {
    DATABASE_URL: "postgres://localhost:5432/db",
    SOME_API_KEY: "sandbox12345",
  },
  test: {
    SOME_API_KEY: "mocked",
  },
  stag: {
    SOME_API_KEY: "sandbox12345",
  },
  prod: {
  },
});

export default config;
export { secrets };

With this setup, it’s easy to trace the usage of any particular config, and especially the secrets:

import config, { secrets } from "./config";
import APIClient from "some-api";

export function getAPIClient () {
  return new APIClient(config.SOME_API_URL, {
    token: secrets.SOME_API_KEY,
  });
}

ENVIRONMENT names

Name the ENVIRONMENTS by their primary functional difference, rather than a specific stage, app, or instance names. Some suggestions:

  • "prod" is for live production instances, ones where it matters when things go wrong
  • "dev" is any non-production deployment, be it Staging, QA, UAT, SIT, or any other name or acronym, potentially even locally on a developer machine (there could be any number of deployments or stages in a pipeline using this ENVIRONMENT)
  • "test" is any CI or similar test context that needs specific changes for mocking or instrumentation
  • "local" is a good alternative to "dev" when perhaps certain infrastructure pieces like queues need to be simulated

If there are more than a few ENVIRONMENTs, that’s a hint the config needs a simplification pass. Likewise if each environment needs to set a lot of variables, reconsider how different the environments need to be from each other.

Note that different deployments can share an ENVIRONMENT but set different values in their specific instance. The EVIRONMENT is just a label that selects a set of config defaults and expectations as defined in the code.

12 Factor Config

I thought the 12 Factor App said to store config in the environment.

It does, and it’s right! defaulted is more precisely a way to manage the defaults, and ensure consistency. It seeks a balance between the purity of env-based config with the practicality of consistent groupings with centralized declarative defaults.

In practice, unless you’re always doing it live there usually are at least two classes of config: production and non-production. External tools can help manage this, but apps should own their expectations of configuration while allowing for explicit overrides on a per-deployment basis.

Why not NODE_ENV?

Apps and tooling have different interpretations of NODE_ENV, some only allowing specific values. The consensus seems to be it refers to a build-time performance configuration rather than a deployment context. When NODE_ENV=production, bundles are minified, debug instrumentation disabled, dev dependencies omitted etc. Those changes are important for production, but they’re also valuable for any non-prod environment where you want to test code that is as close to the actual prod version as possible. They’re also necessary to practice stateless builds that can be promoted to production. Besides, NODE_ENV is node-specific, whereas ENVIRONMENT does not imply a particular technology, so other services can respond to it without confusion.

Nesting or lists

Storing more complex values in the env beyond a singular token is not recommended. However, you are free to use regular strings for that and parse them yourself. defaulted doesn’t support validating such values because the schemas quickly become complicated or app/technology-specific, and are better handled by the app itself if they are absolutely necessary. Odds are they are better suited as data rather than config.

Key names

To maximize compatibility, and reinforce the notion that these values should be constant for the life of the process, use uppercase with underscores, eg FEATURE_FLAG.

Any valid object property name is allowed for defaults. This includes mixed cases, spaces, and non-alphanumeric characters. That said, spaces are disallowed in most POSIX environments, and some even disallow lowercase. Others are case-insensitive. Even if there is a default defined, the app may not be able to read some key from the environment.

Author

Alec Perkins

License

This package is licensed under the MIT License.

See ./LICENSE for more information.