DEV Community

Wilson Xu
Wilson Xu

Posted on

The Complete Guide to Config File Parsing in Node.js CLI Tools

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"]
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Parse with the yaml package (formerly js-yaml):

import { parse } from 'yaml';
import { readFileSync } from 'fs';

const config = parse(readFileSync('.myconfig.yml', 'utf8'));
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Use the @iarna/toml or smol-toml package:

import { parse } from 'smol-toml';
const config = parse(readFileSync('config.toml', 'utf8'));
Enter fullscreen mode Exit fullscreen mode

.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
Enter fullscreen mode Exit fullscreen mode

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
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

By default, cosmiconfig searches for (in order):

  1. package.json "mytool" property
  2. .mytoolrc (JSON or YAML)
  3. .mytoolrc.json
  4. .mytoolrc.yaml / .mytoolrc.yml
  5. .mytoolrc.js / .mytoolrc.cjs
  6. 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',
  ],
});
Enter fullscreen mode Exit fullscreen mode

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:

  1. CLI arguments — explicit user intent, always wins
  2. Environment variables — session-level overrides
  3. Project config.mytoolrc in the project directory
  4. User config~/.config/mytool/config.json (XDG convention)
  5. System config/etc/mytool/config.json
  6. 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,
  };
}
Enter fullscreen mode Exit fullscreen mode

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,
]);
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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"
Enter fullscreen mode Exit fullscreen mode

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 });
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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);
  });
}
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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.`);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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`);
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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}"
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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" }
    }
  ]
}
Enter fullscreen mode Exit fullscreen mode

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' },
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

The loading flow:

  1. Parse CLI arguments (e.g., with commander or yargs)
  2. Use cosmiconfig to find and load the config file
  3. Load global config from ~/.config/mytool/
  4. Load environment variables with the MYTOOL_ prefix
  5. Run migrations if the config version is outdated
  6. Deep merge all sources in priority order
  7. Validate with Zod
  8. Scan for secrets and warn
  9. 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)