The Complete Guide to Config File Parsing in Node.js CLI Tools
Every serious CLI tool needs configuration. Users want to tweak behavior without passing twenty flags on every invocation. But configuration handling is deceptively complex: you need to support multiple file formats, merge settings from different sources, validate schemas, handle migrations, and protect secrets — all without making the developer experience painful.
This guide covers everything you need to build production-grade configuration handling in Node.js CLI tools, drawing on patterns from ESLint, Prettier, Babel, and dozens of real-world tools.
Config File Formats: Choosing the Right One
The Node.js ecosystem has settled on several config file formats, each with distinct strengths.
JSON
The most common format. Node.js parses it natively with require() or JSON.parse(). The downside: no comments, no trailing commas, and verbose syntax.
{
"port": 3000,
"verbose": true,
"plugins": ["auth", "logging"]
}
Many tools work around the comments limitation by using JSON5 or JSONC parsers. VS Code's settings.json uses JSONC, and TypeScript's tsconfig.json does the same.
YAML
Favored in DevOps tooling and anywhere humans edit configs frequently. YAML supports comments, multiline strings, and anchors for DRY references.
port: 3000
verbose: true
plugins:
- auth
- logging
Parse with the yaml package (formerly js-yaml):
import { parse } from 'yaml';
import { readFileSync } from 'fs';
const config = parse(readFileSync('.myconfig.yml', 'utf8'));
TOML
Growing in popularity thanks to Rust tooling and its clarity for nested structures. TOML distinguishes between inline tables and sections, making deeply nested configs readable.
port = 3000
verbose = true
plugins = ["auth", "logging"]
[database]
host = "localhost"
port = 5432
Use the @iarna/toml or smol-toml package:
import { parse } from 'smol-toml';
const config = parse(readFileSync('config.toml', 'utf8'));
.env Files
Not strictly config files, but essential for secrets and environment-specific values. The dotenv package is the standard:
DATABASE_URL=postgres://localhost:5432/mydb
API_KEY=sk-abc123
DEBUG=true
RC Files
The Unix tradition of dotfiles lives on. Tools like ESLint (.eslintrc), Babel (.babelrc), and npm (.npmrc) use this pattern. RC files can be JSON, YAML, or INI format depending on the tool.
package.json Fields
Many tools allow configuration inside package.json to reduce file clutter. Prettier reads from "prettier", Jest from "jest", Babel from "babel":
{
"name": "my-project",
"prettier": {
"semi": false,
"singleQuote": true
}
}
This is popular because it requires zero additional files, but it does not work for monorepos where you need per-package configs.
The Cosmiconfig Pattern: Automatic Config Discovery
Writing config-loading logic from scratch is tedious. The cosmiconfig package solves this by searching for configuration in a standard sequence of locations.
import { cosmiconfig } from 'cosmiconfig';
const explorer = cosmiconfig('mytool');
const result = await explorer.search();
if (result) {
console.log('Config found at:', result.filepath);
console.log('Config:', result.config);
}
By default, cosmiconfig searches for (in order):
-
package.json"mytool"property -
.mytoolrc(JSON or YAML) .mytoolrc.json-
.mytoolrc.yaml/.mytoolrc.yml -
.mytoolrc.js/.mytoolrc.cjs -
mytool.config.js/mytool.config.cjs
It walks up the directory tree from the current working directory, so a config file in a parent directory automatically applies to all subdirectories — perfect for monorepo setups.
Extending Cosmiconfig
You can add TOML support or custom loaders:
import { cosmiconfig } from 'cosmiconfig';
import { parse as parseToml } from 'smol-toml';
const explorer = cosmiconfig('mytool', {
loaders: {
'.toml': (filepath, content) => parseToml(content),
},
searchPlaces: [
'package.json',
'.mytoolrc',
'.mytoolrc.json',
'.mytoolrc.toml',
'mytool.config.js',
'mytool.config.ts',
],
});
How ESLint Does It
ESLint's flat config system (eslint.config.js) moved away from cosmiconfig to a single-file approach. But its legacy system is a textbook cosmiconfig implementation: .eslintrc.js, .eslintrc.yml, .eslintrc.json, and package.json "eslintConfig" — all searched up the directory tree with cascading merges.
Merging Configs: The Priority Hierarchy
Real-world CLI tools draw configuration from multiple sources. The standard priority order, from highest to lowest:
- CLI arguments — explicit user intent, always wins
- Environment variables — session-level overrides
-
Project config —
.mytoolrcin the project directory -
User config —
~/.config/mytool/config.json(XDG convention) -
System config —
/etc/mytool/config.json - Built-in defaults — hardcoded fallbacks
Here is a practical implementation:
import { cosmiconfig } from 'cosmiconfig';
import { resolve } from 'path';
import { existsSync, readFileSync } from 'fs';
const DEFAULTS = {
port: 3000,
verbose: false,
format: 'json',
maxRetries: 3,
};
function loadGlobalConfig() {
const globalPath = resolve(
process.env.XDG_CONFIG_HOME || `${process.env.HOME}/.config`,
'mytool',
'config.json'
);
if (existsSync(globalPath)) {
return JSON.parse(readFileSync(globalPath, 'utf8'));
}
return {};
}
function loadEnvConfig() {
const config = {};
if (process.env.MYTOOL_PORT) config.port = parseInt(process.env.MYTOOL_PORT);
if (process.env.MYTOOL_VERBOSE) config.verbose = process.env.MYTOOL_VERBOSE === 'true';
if (process.env.MYTOOL_FORMAT) config.format = process.env.MYTOOL_FORMAT;
return config;
}
export async function resolveConfig(cliArgs = {}) {
const explorer = cosmiconfig('mytool');
const projectResult = await explorer.search();
const projectConfig = projectResult?.config || {};
const globalConfig = loadGlobalConfig();
const envConfig = loadEnvConfig();
// Merge in priority order (later spreads override earlier)
return {
...DEFAULTS,
...globalConfig,
...projectConfig,
...envConfig,
...cliArgs,
};
}
Deep Merging
Shallow spreading works for flat configs, but nested objects need deep merging. The deepmerge package handles this correctly:
import deepmerge from 'deepmerge';
const config = deepmerge.all([
DEFAULTS,
globalConfig,
projectConfig,
envConfig,
cliArgs,
]);
Watch out for array merging behavior. By default, deepmerge concatenates arrays. If your users expect arrays to replace rather than append, pass a custom merge function:
const config = deepmerge.all([...sources], {
arrayMerge: (target, source) => source, // replace, don't concat
});
Schema Validation with Zod
Raw config objects are dangerous. A typo like "prot": 3000 instead of "port": 3000 silently does nothing. Schema validation catches these errors early and provides helpful messages.
Zod is the modern choice for TypeScript projects:
import { z } from 'zod';
const ConfigSchema = z.object({
port: z.number().int().min(1).max(65535).default(3000),
verbose: z.boolean().default(false),
format: z.enum(['json', 'csv', 'table']).default('json'),
maxRetries: z.number().int().min(0).max(10).default(3),
output: z.string().optional(),
plugins: z.array(z.string()).default([]),
database: z.object({
host: z.string().default('localhost'),
port: z.number().default(5432),
ssl: z.boolean().default(false),
}).optional(),
});
type Config = z.infer<typeof ConfigSchema>;
export function validateConfig(raw: unknown): Config {
const result = ConfigSchema.safeParse(raw);
if (!result.success) {
const errors = result.error.issues
.map(i => ` - ${i.path.join('.')}: ${i.message}`)
.join('\n');
console.error(`Invalid configuration:\n${errors}`);
process.exit(1);
}
return result.data;
}
Handling Unknown Keys
By default, Zod strips unknown keys. To warn users about typos, use .strict():
const StrictConfigSchema = ConfigSchema.strict();
// Throws on { "prot": 3000 } — "Unrecognized key: prot"
Or use .passthrough() if you want to allow (and preserve) extra keys for plugin configs.
Joi Alternative
If you are not using TypeScript, Joi remains a solid choice:
import Joi from 'joi';
const schema = Joi.object({
port: Joi.number().integer().min(1).max(65535).default(3000),
verbose: Joi.boolean().default(false),
format: Joi.string().valid('json', 'csv', 'table').default('json'),
});
const { error, value } = schema.validate(rawConfig, { abortEarly: false });
Type-Safe Config with TypeScript
The real power of Zod is that z.infer<typeof schema> gives you a TypeScript type automatically. You never write the config interface by hand — it stays in sync with your validation schema.
A complete pattern for type-safe config:
// config/schema.ts
import { z } from 'zod';
export const ConfigSchema = z.object({
port: z.number().default(3000),
logLevel: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
features: z.object({
darkMode: z.boolean().default(false),
experimentalApi: z.boolean().default(false),
}).default({}),
});
export type Config = z.infer<typeof ConfigSchema>;
// config/index.ts
import { cosmiconfig } from 'cosmiconfig';
import { ConfigSchema, type Config } from './schema.js';
let cachedConfig: Config | null = null;
export async function getConfig(): Promise<Config> {
if (cachedConfig) return cachedConfig;
const explorer = cosmiconfig('mytool');
const result = await explorer.search();
cachedConfig = ConfigSchema.parse(result?.config ?? {});
return cachedConfig;
}
Now every part of your codebase that calls getConfig() gets full type inference, autocomplete, and compile-time checking.
Hot Reloading Config Files with fs.watch
Long-running CLI tools (dev servers, watchers, daemons) benefit from reloading config when the file changes. The fs.watch API handles this, but it has platform quirks you need to handle.
import { watch } from 'fs';
import { cosmiconfig } from 'cosmiconfig';
function watchConfig(configPath, onReload) {
let debounceTimer = null;
watch(configPath, (eventType) => {
if (eventType !== 'change') return;
// Debounce: editors often trigger multiple change events
clearTimeout(debounceTimer);
debounceTimer = setTimeout(async () => {
try {
const explorer = cosmiconfig('mytool');
const result = await explorer.load(configPath);
console.log('Config reloaded from', configPath);
onReload(result.config);
} catch (err) {
console.error('Failed to reload config:', err.message);
// Keep using the previous valid config
}
}, 100);
});
}
Key Considerations
Debouncing is essential. Text editors like VS Code save in multiple steps — write to temp file, rename, delete old — which triggers multiple change events. A 100ms debounce collapses these into one reload.
Validation on reload prevents a syntax error from crashing your tool. Always validate the new config before applying it, and fall back to the previous valid config on failure.
Chokidar is a more reliable alternative to fs.watch on platforms with spotty filesystem event support:
import chokidar from 'chokidar';
chokidar.watch('.mytoolrc').on('change', async () => {
// reload logic
});
Generating Default Config Files: mytool init
Every good CLI tool provides an init command that generates a starter config file. This lowers the barrier to entry and documents available options.
import { writeFileSync, existsSync } from 'fs';
const DEFAULT_CONFIG = `{
// Port for the dev server
"port": 3000,
// Log level: "debug" | "info" | "warn" | "error"
"logLevel": "info",
// Enabled plugins
"plugins": [],
// Output format
"format": "json"
}
`;
export function initConfig(format = 'json') {
const filename = '.mytoolrc';
if (existsSync(filename)) {
console.error(`${filename} already exists. Use --force to overwrite.`);
process.exit(1);
}
writeFileSync(filename, DEFAULT_CONFIG);
console.log(`Created ${filename} with default configuration.`);
}
Interactive Init
For tools with many options, an interactive init is more user-friendly. The @inquirer/prompts package handles this well:
import { select, confirm, input } from '@inquirer/prompts';
export async function interactiveInit() {
const format = await select({
message: 'Config file format?',
choices: [
{ name: 'JSON', value: 'json' },
{ name: 'YAML', value: 'yaml' },
{ name: 'TOML', value: 'toml' },
],
});
const port = await input({
message: 'Port number?',
default: '3000',
validate: (v) => /^\d+$/.test(v) || 'Must be a number',
});
const verbose = await confirm({
message: 'Enable verbose logging?',
default: false,
});
// Generate config in chosen format
const config = { port: parseInt(port), verbose };
writeConfig(config, format);
}
Prettier's init command writes a minimal .prettierrc and optionally creates a .prettierignore. Babel's init scaffolds a full babel.config.json with detected presets based on the project's dependencies.
Config Migration Between Versions
When your tool's config schema changes between versions, you need a migration strategy. Without one, users upgrading your tool face cryptic errors.
const CURRENT_VERSION = 3;
const migrations = {
1: (config) => {
// v1 -> v2: "outputDir" renamed to "outDir"
if (config.outputDir) {
config.outDir = config.outputDir;
delete config.outputDir;
}
config._version = 2;
return config;
},
2: (config) => {
// v2 -> v3: "plugins" changed from string[] to object[]
if (Array.isArray(config.plugins)) {
config.plugins = config.plugins.map(p =>
typeof p === 'string' ? { name: p, options: {} } : p
);
}
config._version = 3;
return config;
},
};
export function migrateConfig(config) {
let version = config._version || 1;
while (version < CURRENT_VERSION) {
const migrate = migrations[version];
if (!migrate) {
throw new Error(`No migration path from config version ${version}`);
}
config = migrate(config);
version = config._version;
console.log(`Migrated config to version ${version}`);
}
return config;
}
Best Practices for Config Migration
Always version your config. Add a _version or $schema field from day one. It costs nothing and saves enormous pain later.
Make migrations idempotent. Running the same migration twice should produce the same result. Guard every transformation with a check.
Offer a migrate command. Let users run mytool migrate to update their config file in place, with a backup:
import { copyFileSync, writeFileSync } from 'fs';
function migrateCommand(configPath) {
copyFileSync(configPath, `${configPath}.backup`);
const raw = JSON.parse(readFileSync(configPath, 'utf8'));
const migrated = migrateConfig(raw);
writeFileSync(configPath, JSON.stringify(migrated, null, 2));
console.log(`Config migrated. Backup saved to ${configPath}.backup`);
}
Secrets in Config: Detecting and Warning About Exposed Secrets
Config files frequently end up in version control. If they contain API keys, database passwords, or tokens, that is a security incident waiting to happen.
Build detection into your config loader:
const SECRET_PATTERNS = [
{ pattern: /^sk[-_]/, label: 'API secret key' },
{ pattern: /^ghp_/, label: 'GitHub personal access token' },
{ pattern: /^xox[bporas]-/, label: 'Slack token' },
{ pattern: /-----BEGIN (RSA |EC )?PRIVATE KEY-----/, label: 'Private key' },
{ pattern: /^AKIA[0-9A-Z]{16}$/, label: 'AWS access key' },
{ pattern: /:\/\/[^:]+:[^@]+@/, label: 'URL with credentials' },
];
function scanForSecrets(config, path = '') {
const warnings = [];
for (const [key, value] of Object.entries(config)) {
const fullPath = path ? `${path}.${key}` : key;
if (typeof value === 'object' && value !== null) {
warnings.push(...scanForSecrets(value, fullPath));
continue;
}
if (typeof value !== 'string') continue;
for (const { pattern, label } of SECRET_PATTERNS) {
if (pattern.test(value)) {
warnings.push(`WARNING: "${fullPath}" appears to contain a ${label}`);
}
}
}
return warnings;
}
export function loadConfigWithSecretCheck(config, filepath) {
const warnings = scanForSecrets(config);
if (warnings.length > 0) {
console.error('\n⚠ Potential secrets detected in config file:');
warnings.forEach(w => console.error(` ${w}`));
console.error(`\nConsider using environment variables instead.`);
console.error(`Add ${filepath} to .gitignore if it must contain secrets.\n`);
}
return config;
}
The Environment Variable Pattern
The robust solution: reference environment variables in config files rather than embedding secrets.
{
"database": {
"host": "localhost",
"password": "${DATABASE_PASSWORD}"
},
"apiKey": "${API_KEY}"
}
Then resolve them at load time:
function resolveEnvVars(config) {
const json = JSON.stringify(config);
const resolved = json.replace(/\$\{(\w+)\}/g, (match, envVar) => {
const value = process.env[envVar];
if (!value) {
console.error(`Missing environment variable: ${envVar}`);
process.exit(1);
}
return value;
});
return JSON.parse(resolved);
}
This is the pattern used by Docker Compose, Kubernetes manifests, and many CI/CD tools.
Real Examples from Popular Tools
ESLint
ESLint's new flat config (eslint.config.js) is a single JavaScript file that exports an array of config objects. Each object can target specific files with files globs. The old system used cosmiconfig with cascading — configs in subdirectories extended and overrode parent configs.
Prettier
Prettier uses cosmiconfig verbatim. It searches for .prettierrc, .prettierrc.json, .prettierrc.yaml, .prettierrc.js, prettier.config.js, and package.json's "prettier" field. It also supports an overrides array for per-file-pattern config:
{
"semi": false,
"overrides": [
{
"files": "*.md",
"options": { "proseWrap": "always" }
}
]
}
Babel
Babel distinguishes between project-wide config (babel.config.json at the repo root) and file-relative config (.babelrc in subdirectories). This two-tier system was designed specifically for monorepos: shared presets at the root, per-package overrides in subdirectories.
Vite
Vite's vite.config.ts is notable because it is a TypeScript file that Vite transpiles on the fly. Users get full IntelliSense and type checking in their config files. The defineConfig wrapper provides the type hints:
import { defineConfig } from 'vite';
export default defineConfig({
server: { port: 4000 },
build: { outDir: 'dist' },
});
Putting It All Together
Here is the architecture for production-grade config handling in a CLI tool:
src/
config/
schema.ts # Zod schema + TypeScript types
loader.ts # cosmiconfig + env vars + CLI args
merger.ts # deep merge with priority hierarchy
migrate.ts # version-aware migrations
secrets.ts # secret detection + env var resolution
watcher.ts # hot reload for dev mode
init.ts # generate default config
index.ts # CLI entry point
The loading flow:
- Parse CLI arguments (e.g., with
commanderoryargs) - Use cosmiconfig to find and load the config file
- Load global config from
~/.config/mytool/ - Load environment variables with the
MYTOOL_prefix - Run migrations if the config version is outdated
- Deep merge all sources in priority order
- Validate with Zod
- Scan for secrets and warn
- Cache the result for the session
This pattern scales from simple scripts to complex tools with hundreds of configuration options. Start with cosmiconfig and Zod, add the other layers as your tool grows.
The best config system is the one that stays out of the user's way: sensible defaults mean most users never create a config file, power users get the flexibility they need, and everyone gets clear error messages when something is wrong.
Top comments (0)