Skip to content

Adapting 12 factor app configuration to a type checked, application focused world view.

License

Notifications You must be signed in to change notification settings

codemariner/tconf

Repository files navigation

tconf


Version Downloads/month License

Adapting 12 factor app configuration to a type checked, application focused world view.

Features

  • Hierarchical configuration - values are merged from multiple sources.
  • Supported file formats: yaml, json, json5
  • Environment specific configuration via NODE_ENV
  • Runtime type validation.
  • Support for modulare configuration.
  • Type coercion of environment variables - string values can be converted to:
    • number
    • boolean
    • Date
    • RegExp
    • Array<number|boolean|Date|RegExp>
  • All values can implicitly be configured by environment variables.

Overview

12 factor app guidelines for configuration promotes "strict separation of config from code" through the use of environment variables. While this is beneficial from a deployment perspective, how this is implemented in many cases falls short of adequately supporting complex configuration within an application.

Typical approaches involve referencing process.env directly, perhaps with additional support through a library like dotenv. These applications often start by working with a flat list of variables.

const {
    DB_HOST,
    DB_USERNAME,
    DB_PASSWORD,
    // ...
} = process.env;

As configuration becomes more complex, this flat structure becomes cumbersome to deal with and to reason about. To combat this, developers will organize their configuration into a hierarchical structure. Having to map from a flat list of env vars into a desired shape, performing type coercion from env var strings, and executing validation is often an exercise left for the developer. For example, a desired end state for your configuration might look like:

api: {
  baseUrl: string
  port?: number
  debugMode?: boolean
  auth: {
    secret: string
  }
}
database: {
  host: string
  username: string
  password: string
  driverOpts?: {
    connectionTimeout?: number
    queryTimeout?: number
  }
}
...

Representing this as a flat list of env vars is not an effective way to work with your configuration. tconf addresses this by allowing authors to specify the desired shape and type of their configuration and performs mapping and coercion from environment variables automatically.

Getting Started

1. install

npm install tconf

2. create config specification (optional)

tconf utilizes runtypes for runtime type checking and as a schema for your config. This represents what you want your config to look like.

// src/config.ts
import { Boolean, Optional, Record, Static, String } from 'runtypes';

const ApiConfig = Record({  
    port: number,
    debug: Optional(Boolean)
})
const DatabaseConfig = Record({
  host: String,
  username: String,
  password: Optional(String)
})

const Config = Record({
    api: ApiConfig,
    database: DatabaseConfig
});
export type Config = Static<typeof Config>;

where the type Config is inferred as:

interface Config {
  api: {
    port: number
    debug?: boolean
  },
  database: {
    host: string
    username: string
    password?: string
  }
}

If you aren't using TypeScript or don't care about having your configuration statically typed, coerced, and validated then you can skip this.

3. map to env var names (optional)

Create a config file that defines a mapping of env vars. tconf provides support for template variables that can be used for env var interpolation (similar to docker compose) and also allows for assigning default values.

# config/env.yaml
api:
  port: ${API_PORT:3000}
database:
  host: ${DB_HOST:"db.domain.com"}
  username: ${DB_USER}
  password: ${DB_PASSWORD}

This is also optional. tconf natively supports configuration mapping from environment variables following a path naming convention. (you can set any configuration value using an environment variable). Use interpolation variables in your config only if you need to map from some specifically named variable that doesn't match your config.

4. load your configuration

// src/config.ts
import { initialize } from 'tconf'

const tconf = initialize({
  // directories containing configuration files
  path: path.join(__dirname, '..', 'config'),
  // the runtypes Config object (optional)
  schema: Config,
  // sources to look for config, in this case the files
  // default.yaml, ${NODE_ENV}.yaml, and env.yaml
  sources: ['default', 'NODE_ENV', 'env'],
})
export default tconf.get();

tconf will import configurations from the defined sources (or a set of defaults) from the specified directories, and merge the values in the order of the specified sources.

5. use it

// src/foo.ts
import config from './config'
import dbConnect from './db'

const conn = await dbConnect(config.database);

6. use in isolated modules

Within larger applications, you may want to isolate certain areas of your code into modules. It may make sense to isolate your configuration to such modules as well.

First, expose your initialized Tconf instance:

// src/config.ts
import { initialize } from 'tconf'

export const tconf = initialize({ // <-- export the instance
    // ...
})
export default tconf.get(); // exports the configuration

Then in your module, register your configuration schema and provide access to your module.

// src/modules/db/config.ts
import {tconf} from '../../config'

const Config = Record({
    uri: String
})

const config = tconf.register('database', Config); // Static<typeof Config>

export default config

The configuration will be sourced the same way, but you'll need to add your configuration under the registered name.

# config/default.yaml
api:
  # //...

database:
  uri: postgresql://host.com:5432/appdb

Documentation

Please see the documentation for more detailed information and capabilities of tconf.