Build a JSON6 Config Loader
A commented .json6 config for Node.js with layering, environment overrides, validation and strict parsing.
What you will build: A tested loader that reads default.json6 plus an environment file, applies APP_* variables, validates the result and freezes it. It guards against the four JSON6 pitfalls: trailing text, __proto__, leading zeros and anonymous errors.
What We Are Building
A configuration loader for a Node.js service that reads a commented .json6 file for defaults, layers an environment-specific file on top, lets environment variables override individual settings, validates the result, and freezes it. It uses nothing but json-6 and Node's own modules, and every listing on this page was run: the outputs are real.
config/ default.json6 shared settings, fully commented production.json6 only what differs in production src/ config.js the loader app.js uses it test/ config.test.jsThe defaults file is where comments earn their keep. It documents each setting where it is defined, which is the main reason to use JSON6 instead of JSON here:
// config/default.json6 - settings shared by every environment. // Comments, unquoted keys and trailing commas are all fine here. { server: { host: 'localhost', port: 3000, // Milliseconds before an idle connection is closed. idleTimeoutMs: 30_000, }, log: { level: 'info', // debug | info | warn | error }, features: { beta: false, /* Flip this to true to try the new importer. */ newImporter: false, }, allowedOrigins: [ 'http://localhost:3000', ], }The environment file repeats only the settings that change:
// config/production.json6 - only what differs in production. { server: { host: '0.0.0.0', port: 8080, }, log: { level: 'warn' }, allowedOrigins: [ 'https://app.example.com', 'https://admin.example.com', ], }The Two-Line Version
If your config is trusted and you only want comments, Node can load the file directly. Register the hook once, then require the file like JSON:
require('json-6/lib/require'); const config = require('./config/default.json6'); console.log(config.server.port, config.log.level);3000 infoFor many scripts this is enough. For a service that other people deploy, it is not, for the reasons in the next section.
Why a Real Loader Needs More
Hand-edited config fails in ways a data parser alone does not catch. Four of them are specific to JSON6, and the loader below addresses each:
- Silent truncation.
JSON6.parsestops after the first complete value and ignores the rest. An extra}mid-file drops every setting after it. An unclosed brace gives the unhelpful "No value found in document". - The
__proto__key. JSON6 assigns it instead of creating a property, so a config file can change the prototype of the object you receive. - Leading zeros.
port: 0080parses as 80 here but as an octal literal if the file is ever pasted into JavaScript, and0123in a zip-code field loses its zero. - No file name in errors. The parser knows only the text. Its message ends in
[12:5]with no hint of which file.
Two further problems are not about JSON6 at all: a typo such as sever silently creates an unused setting, and a value of the wrong type (a port of "80") fails somewhere far from the file. Validation fixes both.
Step 1: Parse Strictly
Start the loader with the options and a schema. The parse options are the strict ones: esStrictCompatible rejects leading-zero numbers, raw newlines in quoted strings, multiple signs and unusual bare keys, and forbidTemplateSubstitution rejects ${ inside back-tick strings. Both are off by default.
'use strict'; // src/config.js - load, layer, override, validate and freeze the app config. const fs = require('node:fs'); const path = require('node:path'); const JSON6 = require('json-6'); // Hand-edited config should not be able to mean one thing to us and another // thing to a JavaScript engine, so hold it to the stricter grammar. const PARSE_OPTIONS = { esStrictCompatible: true, forbidTemplateSubstitution: true }; const SCHEMA = { 'server.host': { type: 'string' }, 'server.port': { type: 'integer', min: 1, max: 65535 }, 'server.idleTimeoutMs': { type: 'integer', min: 0 }, 'log.level': { type: 'string', oneOf: ['debug', 'info', 'warn', 'error'] }, 'features.beta': { type: 'boolean' }, 'features.newImporter': { type: 'boolean' }, 'allowedOrigins': { type: 'array' }, }; The trade-off is explicit: with esStrictCompatible on, a bare key like content-type must be quoted. For a config file that is an acceptable price for fewer surprises. Next, the parse function. It does two passes so that it can insist on exactly one value:
// Add the file name and line:column to a parser error. The parser's own // message already ends in "[line:col]", so lift that out and put it first. function located(err, label) { if (/^No value found/.test(err.message)) { return new Error(`${label}: no value found (empty file, or an unclosed { or [)`, { cause: err }); } const at = /\[(\d+):(\d+)\]\s*$/.exec(err.message); const where = at ? `:${at[1]}:${at[2]}` : ''; return new Error(`${label}${where} ${err.message.replace(/\n/g, '\\n')}`, { cause: err }); } // JSON6.parse() returns the first value and ignores everything after it, so a // stray "}" would silently drop the rest of the file. To insist on exactly one // value, parse the text a second time wrapped in an array with a sentinel // after it. The result is [value, END] only if the text held one complete // value and nothing else (no trailing text, no second value, no extra bracket). const END = '--end--'; function parseStrict(text, label) { let value; try { value = JSON6.parse(text, undefined, PARSE_OPTIONS); } catch (err) { throw located(err, label); } let wrapped; try { wrapped = JSON6.parse(`[${text}\n,"${END}"]`, undefined, PARSE_OPTIONS); } catch (err) { throw new Error(`${label}: unexpected text after the first value, or an unclosed bracket`, { cause: err }); } if (wrapped.length !== 2 || !(0 in wrapped) || wrapped[1] !== END) { throw new Error(`${label}: expected exactly one value`); } return value; } The first pass is the ordinary parse(), which reports real syntax errors with accurate positions. The second pass wraps the same text as [ text , "--end--"]. If the text held exactly one value, the result is a two-element array ending in the sentinel; trailing text, a second value, an extra } or ], an unclosed bracket, a dangling comma or an empty file each break that shape or make the wrapped parse fail. A newline is added before the comma so a final // comment cannot swallow it. The located helper lifts the [line:col] from the end of the message to the front, after the file name, so errors read production.json6:3:14 ..., and { cause: err } keeps the original error attached.
Then the prototype guard. A parsed object whose prototype is anything other than Object.prototype or Array.prototype can only have come from a __proto__ key, so the loader refuses it:
// A "__proto__" key makes JSON6.parse() change the prototype of the object it // is building. Reject any parsed object that is not a plain object or array. function assertPlain(value, label, trail = '') { if (value === null || typeof value !== 'object') return; const proto = Object.getPrototypeOf(value); if (proto !== Object.prototype && proto !== Array.prototype) { throw new Error(`${label}: "${trail || '(root)'}" has a custom prototype (a __proto__ key?)`); } for (const [key, child] of Object.entries(value)) { assertPlain(child, label, trail ? `${trail}.${key}` : key); } } function isPlainObject(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); } function merge(base, over) { if (!isPlainObject(base) || !isPlainObject(over)) return over; const out = { ...base }; for (const [key, value] of Object.entries(over)) { out[key] = key in base ? merge(base[key], value) : value; } return out; }Step 2: Layer the Files
The loader reads default.json6, which is required, and then <env>.json6, which may be absent. merge above combines them: nested objects merge key by key, and everything else, arrays included, is replaced wholesale. That is the least surprising rule for settings such as allowedOrigins, where merging two lists would be wrong.
function readFile(file, required) { let text; try { text = fs.readFileSync(file, 'utf8'); } catch (err) { if (err.code === 'ENOENT' && !required) return {}; throw err; } const value = parseStrict(text, path.basename(file)); assertPlain(value, path.basename(file)); return value; } A missing environment file is not an error, but any other read problem, such as a permissions failure, is. Both files go through parseStrict and assertPlain with the file name as the label.
Step 3: Environment Overrides
Deployments often need to change a single value without editing a file. The convention here is APP_ followed by the path, with a double underscore between levels: APP_SERVER__PORT overrides server.port. Matching ignores case and underscores, so APP_SERVER__IDLE_TIMEOUT_MS finds idleTimeoutMs.
// APP_SERVER__PORT=9000 -> config.server.port. "__" descends one level and // matching ignores case and underscores, so APP_LOG__LEVEL and // APP_SERVER__IDLE_TIMEOUT_MS find log.level and server.idleTimeoutMs. function norm(name) { return name.toLowerCase().replace(/_/g, ''); } function coerce(raw) { try { return parseStrict(raw, 'env'); } catch { return raw; // not valid JSON6: keep it as a string ("info", "0.0.0.0") } } function applyEnv(config, environ, prefix) { const unknown = []; for (const [name, raw] of Object.entries(environ)) { if (!name.startsWith(prefix)) continue; const parts = name.slice(prefix.length).split('__').map(norm); let node = config; for (let i = 0; i < parts.length; i++) { const key = isPlainObject(node) ? Object.keys(node).find((k) => norm(k) === parts[i]) : undefined; if (key === undefined) { unknown.push(name); break; } if (i === parts.length - 1) node[key] = coerce(raw); else node = node[key]; } } if (unknown.length) throw new Error(`Unknown config override(s): ${unknown.join(', ')}`); } Two design choices matter. First, an override can only replace a key that already exists, so a typo such as APP_LOG__LEVL is an error instead of a silently ignored variable:
const { loadConfig } = require('./src/config'); try { loadConfig({ dir: 'config', environ: { APP_LOG__LEVL: 'debug' } }); } catch (err) { console.log(err.message); }Unknown config override(s): APP_LOG__LEVL Second, the value is parsed with the same strict parser, which gives a pleasant side effect: 9000 becomes a number, true a boolean, 1_500 the number 1500, and anything that is not valid JSON6 (debug, 0.0.0.0) stays a string. A value such as 0080 also stays a string, because strict mode rejects it, which keeps identifiers with leading zeros intact. Environment variables are strings and need this step; without it every port would arrive as "9000".
Step 4: Validate and Freeze
Validation runs after layering and overrides, so it checks the final values. It flattens the config to dotted paths, rejects anything not in the schema, then checks type, range and allowed values, collecting every problem into one error so a deploy fails once with the complete list:
function flatten(value, trail = '', out = {}) { if (isPlainObject(value)) { for (const [key, child] of Object.entries(value)) flatten(child, trail ? `${trail}.${key}` : key, out); } else { out[trail] = value; } return out; } function validate(config) { const problems = []; const leaves = flatten(config); for (const name of Object.keys(leaves)) { if (!(name in SCHEMA)) problems.push(`${name}: unknown setting (typo?)`); } for (const [name, rule] of Object.entries(SCHEMA)) { const value = leaves[name]; if (value === undefined) { problems.push(`${name}: missing`); continue; } const ok = rule.type === 'integer' ? Number.isInteger(value) : rule.type === 'array' ? Array.isArray(value) : typeof value === rule.type; if (!ok) { problems.push(`${name}: expected ${rule.type}, got ${JSON.stringify(value)}`); continue; } if (rule.min !== undefined && value < rule.min) problems.push(`${name}: ${value} is below ${rule.min}`); if (rule.max !== undefined && value > rule.max) problems.push(`${name}: ${value} is above ${rule.max}`); if (rule.oneOf && !rule.oneOf.includes(value)) problems.push(`${name}: ${JSON.stringify(value)} is not one of ${rule.oneOf.join(', ')}`); } if (problems.length) throw new Error(`Invalid configuration:\n - ${problems.join('\n - ')}`); }The schema is deliberately tiny. If you already use a schema library, replace this function with it; the rest of the loader does not depend on how validation is done. Finally, the public function ties the steps together and freezes the result so no module can change shared settings at runtime:
function deepFreeze(value) { if (value !== null && typeof value === 'object') { Object.values(value).forEach(deepFreeze); Object.freeze(value); } return value; } function loadConfig({ dir, env = process.env.NODE_ENV || 'development', environ = process.env, prefix = 'APP_' } = {}) { let config = readFile(path.join(dir, 'default.json6'), true); config = merge(config, readFile(path.join(dir, `${env}.json6`), false)); applyEnv(config, environ, prefix); validate(config); return deepFreeze(config); } module.exports = { loadConfig, parseStrict };Using It
Call loadConfig once at startup. src/app.js loads the configuration and prints it as one line, which makes the outputs below easy to compare. First with no environment set (development), then with NODE_ENV=production:
'use strict'; const path = require('node:path'); const { loadConfig } = require('./config'); const config = loadConfig({ dir: path.join(__dirname, '..', 'config') }); console.log(JSON.stringify(config));NODE_ENV=development node src/app.js {"server":{"host":"localhost","port":3000,"idleTimeoutMs":30000},"log":{"level":"info"},"features":{"beta":false,"newImporter":false},"allowedOrigins":["http://localhost:3000"]}NODE_ENV=production node src/app.js {"server":{"host":"0.0.0.0","port":8080,"idleTimeoutMs":30000},"log":{"level":"warn"},"features":{"beta":false,"newImporter":false},"allowedOrigins":["https://app.example.com","https://admin.example.com"]} Production changed the host, port and log level and replaced the origins list, while idleTimeoutMs and the feature flags came from the defaults. Now with overrides from the environment on top of production:
NODE_ENV=production APP_SERVER__PORT=9000 APP_LOG__LEVEL=debug APP_FEATURES__BETA=true APP_SERVER__IDLE_TIMEOUT_MS=1_500 node src/app.js {"server":{"host":"0.0.0.0","port":9000,"idleTimeoutMs":1500},"log":{"level":"debug"},"features":{"beta":true,"newImporter":false},"allowedOrigins":["https://app.example.com","https://admin.example.com"]} What happens when a file is wrong is the point of all the code above. Here is every case in the earlier list, run against a broken production.json6:
// cases.js - what the loader says about broken files const fs = require('node:fs'); const path = require('node:path'); const { loadConfig } = require('./src/config'); const base = fs.readFileSync('config/default.json6', 'utf8'); const cases = { 'missing comma': "{ server: { port: 8080 host: '0.0.0.0' } }", 'extra closing brace': "{ log: { level: 'warn' } }\n}", 'unclosed brace': "{ log: { level: 'warn' }", 'leading zero': '{ server: { port: 0080 } }', 'bad values': "{ server: { port: 99999 }, log: { level: 'verbose' } }", 'typo in a key': '{ sever: { port: 8080 } }', 'prototype key': '{ "__proto__": { admin: true } }', }; for (const [name, text] of Object.entries(cases)) { const dir = path.join('cases', name.replace(/ /g, '-')); fs.mkdirSync(dir, { recursive: true }); fs.writeFileSync(path.join(dir, 'default.json6'), base); fs.writeFileSync(path.join(dir, 'production.json6'), text); try { loadConfig({ dir, env: 'production', environ: {} }); console.log(name.padEnd(20), 'loaded without error'); } catch (err) { console.log(name.padEnd(20), err.message.replace(/\s*\n\s*/g, ' ')); } }missing comma production.json6:1:24 fault parsing 'h' unexpected at 24 (near '80 [h]ost: '0.0.') [1:24] extra closing brace production.json6: unexpected text after the first value, or an unclosed bracket unclosed brace production.json6: no value found (empty file, or an unclosed { or [) leading zero production.json6:1:20 Legacy octal / leading-zero numbers are not allowed when the 'esStrictCompatible' option is set '0' unexpected at 20 (near ': 0[0]80 } }') [1:20] bad values Invalid configuration: - server.port: 99999 is above 65535 - log.level: "verbose" is not one of debug, info, warn, error typo in a key Invalid configuration: - sever.port: unknown setting (typo?) prototype key production.json6: "(root)" has a custom prototype (a __proto__ key?) The first three show the file name, line and column. The extra closing brace, which plain parse() would have accepted, is caught. The unclosed brace gets a plain-language message instead of "No value found in document". The leading zero is rejected in strict mode. The two bad values are reported together. The typo and the prototype key are caught before the service ever starts.
Test the Loader
The loader takes the environment and the variables as arguments, which makes it testable without touching process.env. These tests use Node's built-in runner:
'use strict'; // test/config.test.js const test = require('node:test'); const assert = require('node:assert/strict'); const path = require('node:path'); const { loadConfig } = require('../src/config'); const dir = path.join(__dirname, '..', 'config'); const load = (env, environ = {}) => loadConfig({ dir, env, environ }); test('defaults load and validate', () => { assert.equal(load('development').server.port, 3000); }); test('the production file overrides defaults and keeps the rest', () => { const config = load('production'); assert.equal(config.server.port, 8080); assert.equal(config.server.idleTimeoutMs, 30000); assert.equal(config.allowedOrigins.length, 2); }); test('environment overrides win and are parsed as JSON6', () => { const config = load('production', { APP_SERVER__PORT: '9000', APP_FEATURES__BETA: 'true', APP_LOG__LEVEL: 'debug' }); assert.equal(config.server.port, 9000); assert.equal(config.features.beta, true); assert.equal(config.log.level, 'debug'); }); test('an unknown override is an error', () => { assert.throws(() => load('development', { APP_LOG__LEVL: 'x' }), /Unknown config override/); }); test('an out-of-range value is an error', () => { assert.throws(() => load('development', { APP_SERVER__PORT: '70000' }), /server\.port: 70000 is above 65535/); }); test('the result is frozen', () => { assert.throws(() => { load('development').server.port = 1; }, TypeError); });node --testok 1 - defaults load and validate ok 2 - the production file overrides defaults and keeps the rest ok 3 - environment overrides win and are parsed as JSON6 ok 4 - an unknown override is an error ok 5 - an out-of-range value is an error ok 6 - the result is frozen # tests 6 # pass 6 # fail 0 Add a test that parses every .json6 file in the repository with parseStrict so that a syntax error in a rarely used environment file is caught in CI, not during a release.
Deployment Notes
- Ship the files, not a bundle. The loader reads from disk at startup. Bundlers do not know the
.json6extension, so copyconfig/next to the build output, or read the text yourself and embed it, and resolve the directory with__dirnamerather than the working directory. - No secrets in these files. Comments make the file inviting to annotate, and it will be committed. Put secrets in the environment and let the override mechanism apply them. If a secret has to be a setting, add it to the schema and supply it only through
APP_variables. - Hand JSON to tools that need JSON. If a container health check or a linter wants plain JSON, generate it from the loaded config with
JSON.stringify(config), not withJSON6.stringify, which does not produce JSON. - ES modules. The same code works as ESM. Replace
requirewith imports, useimport JSON6 from 'json-6'(the package has a default export only), and compute the directory fromimport.meta.url. - TypeScript. json-6 ships its own declarations.
parsereturnsunknown, so the validator is also where you narrow the result to your config type.
FAQ
Why use JSON6 for config instead of JSON?
Comments. A settings file that can explain itself, with trailing commas that keep diffs small, is easier to maintain than a JSON file that cannot. The cost is a parser dependency and a file extension that tools do not recognize.
Why not just use the require hook?
The hook is fine for trusted scripts. It uses the default parser, so it ignores trailing text, accepts a __proto__ key and reports errors without a file name. A loader can add strict options and validation.
Can I use esStrictCompatible and keep bare keys with hyphens?
No. With strict mode on, a bare key must be a valid JavaScript identifier or a number, so content-type must be quoted. If you need hyphenated bare keys, leave strict mode off and keep the other guards.
Should the environment variables be nested with double underscores?
It is a convention, not part of JSON6. The double underscore separates levels so that single underscores can stay inside a name, as in IDLE_TIMEOUT_MS. Choose any scheme, as long as unknown names are rejected.
Keep Going
Compare the formats or look up an error message from your own config.