JSON Schema is a convenient way to organize your application’s configuration variables. Your app’s config is, after all, a model. Here are the requirements that I believe should be met in an app’s config setup.

Variables/properties (that one’s a gimmy)

User-friendly descriptions of those variables for future developers or those deploying your open-source app.

Defaults where it makes sense.

A way to group combinations of configuration values according to environment, e.g. development or production.

Incorporation of environment variables into your config. This includes overriding config values with those in the environment, and also converting those environment variables from strings to numbers and booleans. No more if (process.env.myBoolVariable === 'true') .

. (optional) Validation of your config data structure. It’s much better to get a config validation error than to chase it down in your code.

(optional) The ability to update configuration while your app is running. This should be accompanied by the ability to listen for changes to the app config.

(optional) Nested data structures.

In the apps I’ve worked on, I’ve inherited and initiated a wide array of configuration setups. I’ve used dotenv and nconf . I’ve also used setups as simple as a JS file that exports an object. The list above was accumulated from witnessing things others have done that I liked and also lessons learned the hard way. Most setups I’ve used, including dotenv and nconf fail to meet one or more of the bullet points above. nconf ticks the most boxes, but dotenv is far more popular, probably due to it’s simplicity. One thing that I do like from those libraries though is that config is globally accessible. With dotenv config is process.env , and with nconf you just have to require the module, without worrying about the path to the config file. The setup I’m proposing today could work that way too, it would just have to be packaged.

Using JSON Schema for your config enables you to meet many of the requirements, and the others can be met with a minimal amount code. Here’s an example of a config variable in a JSON Schema.

"port": {

"type": "integer",

"description": "The binding port for the server",

"default": 8080,

"minimum": 0

}

Let’s see which requirements we’ve met. We have a variable along with its type, description, and default. On top of that, we can add validation, like minimum. Having the type here enables us to convert the strings that we get in environment variables. The rest of the requirements don’t come as easily, but let’s see how we can attempt to meet them.

The rest of the story

The rest of this post will contain JavaScript code examples for how to get from your JSON Schema config model to a working config setup.

Here is the file stucture for this example.

- config

- schema.json // config model

- development.json // config settings for development

- src

- lib

- observable.js // observable class to hold config values

- config.js // build and export a singleton config observable

- server.js // create http server

Schema

/config/schema.json

{

"$id": "https://github.com/mygithub-handle/my-app-repo/blob/master/config/schema.json",

"$schema": "http://json-schema.org/draft-07/schema#",

"title": "My app config",

"type": "object",

"required": [

"port"

],

"properties": {

"port": {

"type": "integer",

"description": "The binding port for the server",

"default": 8080,

"minimum": 0

}

}

}

Config file

/config/dev.json

For the sake of argument, let’s assume you need to use a different port than the default for development.

{

"port": 3200

}

Observable

/src/lib/observable.js

I’m going to use a simple, custom Observable class for holding the values. I’ve stashed here in a github gist. It’s built with Node’s EventEmitter and ramda . It allows for setting and getting any path, deep merging multiple values, listening for changes, and validating before mutating. Using this observable will allow us to update the config while the app runs and listen for changes to the config. Don’t worry too much about the observable, just how it’s used below.

Dependencies

src/config.js

// ajv is a json-schema validator

const Ajv = require('ajv')

// observable for holding the config values

const Observable = require('./lib/observable')

// schema shown above

const schema = require('../config/schema.json')

// utilities

const R = require('ramda')

const path = require('path')

Environment Variables

/src/config.js

Using the root-level keys from the schema, build an object that reads those keys off of process.env . Also convert the strings to the types in the schema.

// build an object of config values taken from process.env

function buildEnvironmentVariablesConfig (schema) {

const trueRx = /^true$/i

const configKeys = Object.keys(schema.properties)

let env = R.pick(configKeys, process.env)

return Object.keys(env).reduce((acc, key) => {

const { type } = schema.properties[key]

switch (type) {

case 'integer':

return R.assoc(key, parseInt(env[key], 10), acc)

case 'boolean':

return R.assoc(key, trueRx.test(env[key]), acc)

default:

return R.assoc(key, env[key], acc)

}

}, {})

}

Defaults

/src/config.js

Build an object from the default values in the schema.

// build an object using the defaults in the schema

function buildDefaults (schema, definitions) {

return Object.keys(schema.properties).reduce((acc, prop) => {

let spec = schema.properties[prop]

if (spec.$ref) {

spec = definitions[spec.$ref.replace('#/definitions/', '')]

if (spec && spec.type === 'object') {

return R.assoc(prop, buildDefaults(spec, definitions), acc)

}

}

return R.assoc(prop, spec.default, acc)

}, {})

}

Import Config Settings File

/src/config.js

Import the file with the settings for this environment, like development.json , production.json , staging.json , etc. You could point to this file with an environment variable, like CONFIG_FILE. This function gives you the option of passing in an absolute path or a path relative to a config folder that holds the schema.

// import the config file

function buildConfigFromFile (filePath) {

if (!filePath) return {}

const isAbsolutePath = filePath.charAt(0) === '/'

return isAbsolutePath

? require(filePath)

: require(path.join(__dirname, '../config', filePath))

}

Merge the 3 together

/src/config.js

Now that we have 3 objects that represent environment variables, a config file, and defaults, we can merge them together. R.mergeDeepRight will do a deep merge on the defaults and config file values. R.merge will then do a shallow merge of that result and the environment variable values. In both merge functions, the right-most object takes precedence.

// merge the environment variables, config file values, and defaults

let configValues = R.merge(

R.mergeDeepRight(

buildDefaults(schema, schema.definitions),

buildConfigFromFile(process.env.CONFIG_FILE)

),

buildEnvironmentVariablesConfig(schema)

)

Validation Function

/src/config.js

Our validation function is going to throw an error, if the user attempts to set an invalid config value. Another option would be to just return false and log the error.

const ajv = new Ajv()

const ajvValidate = ajv.compile(schema) function validate (data) {

const valid = ajvValidate(data)

if (valid) return true

throw new Error(ajv.errorsText())

}

Initialize the observable and export

/src/config.js

Create the Observable instance using the validate function from above and initialize it with the config values that we just built. Export the singleton config object to use in the app. The Observable will require validate to return true before applying any mutations.

const config = new Observable({ validate })

config.setAllValues(configValues) module.exports = config

Using the config observable

/src/server.js

Here’s a small example of using the port variable.

const config = require('./config')

const http = require('http') const server = http.createServer(/* some middleware callback */)

server.listen(config.get('port'))

Conclusion

Building your app’s configuration on top of JSON Schema and an observable yields a very feature configuration setup.