@neezer/cfg
TypeScript icon, indicating that this package has built-in type declarations

5.5.0 • Public • Published

@neezer/cfg

Build Status semantic-release

cfg is a library to manage loading configuration into your Node JS application. There are many, many libraries that do this already, but they missed some key functionality I was after:

  • Ability to mark variables as optional based on the values of other variables
  • JSON-based schema
  • Rich Typescript config object
  • Configure using XDG-based TOML

cfg offers all of that.

Usage

config.json in your project root:

{
  "aValue": {
    "desc": "A configuration value",
    "env": "A_VALUE",
    "format": "string"
  }
}
/* given `process.env.A_VALUE === 'whatever'` */

import { cfg } from "@neezer/cfg";

// dynamically generated on NPM/yarn install
// if using in a JS project, you can skip this import
import { Config } from "@neezer/cfg/dist/config";

const config = cfg<Config>();

config.aValue === "whatever";

A note about errors

By default, cfg will log configuration errors to stderr and will exit the process with process.exit(1). You can configure this behavior using the onError configuration option.

However, be advised that if you do not properly handle errors, the resulting config object may be in a bad state. I like failing hard when this happens to prevent more cryptic errors, which is why the default is so aggressive.

Example config.json

{
  "boolean": {
    "desc": "A value that will be evaluated to `true` or `false`",
    "env": "BOOL",
    "format": "boolean"
  },
  "number": {
    "desc": "A value that will be evaluated as a Number",
    "env": "NUMBER",
    "format": "number"
  },
  "path": {
    "desc": "A value representing a file path. cfg will check that a file exists at the path given, and throw an error if it doesn't. The return value is the given string.",
    "env": "PATH",
    "format": "path"
  },
  "port": {
    "desc": "A value that will be returned as a number. It must be greater than or equal to 0 and less than or equal to 65535, and it must be an integer.",
    "env": "PORT",
    "format": "port"
  },
  "url": {
    "desc": "A value that will be evaluated as a WHATWG URL object, which will be the return value.",
    "env": "URL",
    "format": "url"
  },
  "inclusion": {
    "desc": "A value that will be tested for equality against the values provided to `format`.",
    "env": "INCLUSION",
    "format": ["one", "two", "three"]
  },
  "any": {
    "desc": "A value that does not specify `format` in one of the above cases will be evaluated as a string.",
    "env": "ANY",
    "format": "whatever"
  }
}

The following properties are optional on any config entity:

property description
caseInsensitive will downcase the input before doing any validation
optional will not emit an error if the value fails format validation
requiredWhen will mark this value as required when the value for this property is evaluated to true. Note that the target value must be format: "boolean".
assembleFrom only valid for URLs, provide a map to build a URL value by parts. See below

requiredWhen Example

{
  "check-me": {
    "desc": "the check value",
    "env": "A",
    "format": "boolean"
  },
  "b": {
    "desc": "the b value",
    "env": "B",
    "format": "string",
    "requiredWhen": "check-me"
  }
}
/**
 * process.env.A === 1
 * process.env.B === 'bananas'
 */
const config = cfg();

config["check-me"] === true;
config.b === "bananas";

/**
 * process.env.A === 0
 */
const config = cfg();

config["check-me"] === false;
config.b === undefined;

/**
 * process.env.A === 1
 */
const config = cfg();

// will exit and emit the error
//
//     value at "b" cannot be undefined

assembleFrom Example

NOTE: This only affects URL values when format is set to url.

Sometimes you can't provide a connection string and have to build it up by parts, specified as individual environment variables. For those cases, you can provide the assembleFrom configuration option, which accepts a map like this:

{
  "url-in-parts": {
    "desc": "a url in parts",
    "env": "URL",
    "format": "url",
    "assembleFrom": {
      "host": "URL_HOST",
      "port": "URL_PORT",
      "protocol": "URL_PROTO",
      "username": "URL_USER",
      "password": "URL_PASS",
      "search": "URL_QUERY_PARAMS",
      "pathname": "URL_PATH"
    }
  }
}

cfg will check to see if the value provided at URL is valid, but if it is not and assembleFrom is present, it will attempt to build a URL from the parts specified in the map. The values in the map are the environment variables you want to use for each part of the URL: the keys are fixed. You can also provide a tuple of values, where the first entry is the environment variable to source from, and the second value is the default value to use if no value was sourced.

If successfull, the final result will be a URL as if you had provided a value for URL.

NOTE: No verification is done on the environment variables listed in assembleFrom before they are passed to the URL constructor internally; they're all read in as simple strings.

Test Mode (allowing partial configs)

Sometimes you want to use cfg with an incomplete configuration. In order for cfg to not throw errors about missing configuration, you'll need to use it in test mode. You can do this by invoking it like this:

cfg.test(/* same options */)

This changes the behavior of cfg to throw errors when properties are accessed and not when the configuration is parsed. Basically it makes cfg lazy instead of eager.

cfg.test will also look for an alternate configuration in config.test.json or in configTest in package.json, and will merge in with the primary configuration schema. This allows you to define variables that only exist in test environments seemlessly without polluting the production configuration schema.

Migrating from 2.x.x to 3.x.x

Automagic Config Type Definition

Previously you had to define a type to pass to cfg, along with a type guard function and an assert function.

Now that all happens automatically at package install time via a postinstall hook. You don't have to pass in the type--cfg will still work fine without it--but you can get better type safety and better autocomplete functionality in your editor if you use it.

If you need to regenerate the definition, you can manually invoke the script:

./node_modules/.bin/cfg

Why at package install time?

TypeScript does not allow inference of rich JS objects at runtime. While you're authoring your application you haven't "ran" it yet, but the compile step for the library has already "run," so there's no opportunity to provide a richer type object in a dynamic fashion. This library cannot anticipate all possible user-generated configurations, which is why the previous API put that work on you--the consumer--to provide to the library.

I always thought it sucked that the type guard and type definition was basically a copy of the config JSON this library consumes. Too much opportunity for the two to drift apart. I was also unhappy with the generic Record type of Record<string, any> since you couldn't write smarter types with that limited information and I really love my VSCode autocompletion.

So this is the happy medium. The compile process for the lib knows nothing about your schema--and thus your config type--but the TypeScript compiler for your application should know about your schema without a bunch of work on your part.

If anyone knows of a better way to implement this, I'm all 👂s.

Configure error handling with onError

The return from cfg now only returns the config object, not the errors. Configure error handling using the onError configuration option.

Migrating from 1.x.x to 2.x.x

The arguments provided to cfg are now an object:

- cfg(givenSchemaMap, assertType)
+ cfg({ schema: givenSchemaMap, check: assertType })

Readme

Keywords

none

Package Sidebar

Install

npm i @neezer/cfg

Weekly Downloads

0

Version

5.5.0

License

MIT

Unpacked Size

62.6 kB

Total Files

68

Last publish

Collaborators

  • neezer