Node.js parseArgs: CLI Flags Without Commander

Node’s parseArgs is a command-line flag parser built into node:util. You describe each option as string or boolean, optionally with a one-letter short alias, multiple: true for repeats and a default, and it returns { values, positionals }. It has been stable since Node 20.0.0, so a small CLI no longer needs commander or yargs.

The catch is scope. parseArgs tokenises and validates flags and does nothing else: no numbers, no required options, no --help text, no subcommands. You write those yourself, and the CLI below shows how much code that turns out to be.

What does parseArgs do with each kind of flag?

The util.parseArgs docs list six config keys: args, options, strict, allowPositionals, allowNegative and tokens. With no args it reads process.argv minus the Node binary and script path. (The source checks for --eval, so node -e scripts work too.)

These are the results we got on Node 24.12 for an option declared as port: { type: 'string', short: 'p' }:

  • --port 8080, --port=8080 and -p8080 all give { port: '8080' }. A string, always.
  • -p 1 -p 2 gives '2'. Without multiple, the last value wins.
  • --port '' gives ''. The docs are explicit that a default is not applied when the option appears, “even if the provided value is falsy”.
  • --port with nothing after it throws Option '-p, --port <value>' argument missing.

Boolean options never take a value, so --color=yes throws Option '--color' does not take an argument. Declare verbose: { type: 'boolean', short: 'v', multiple: true } and -vvv gives [true, true, true], which is how you get a verbosity level from its length.

Everything after a bare -- is a positional, flags included: build -- --port 9 gives positionals ['build', '--port', '9']. And values is created with a null prototype, so call Object.hasOwn(values, 'port'), not values.hasOwnProperty('port').

A complete CLI built on Node parseArgs

This is a line counter: count every line in some files, or only lines containing one or more strings. It shows the parts you have to write yourself. It is TypeScript, and runs unchanged with node linecount.ts on a Node version with type stripping.

import { readFile } from 'node:fs/promises';
import { parseArgs } from 'node:util';

const usage = `Usage: linecount [options] <file...>

Count the lines in each file, or only the lines containing some text.

Options:
  -e, --match <text>  Count lines containing <text> (repeatable)
  -i, --ignore-case   Match without regard to case
      --max <n>       Stop counting a file after <n> lines
      --json          Print JSON instead of a table
  -h, --help          Show this help`;

function fail(message: string): never {
  console.error(`linecount: ${message}\n\n${usage}`);
  process.exit(2);
}

function parse() {
  try {
    return parseArgs({
      options: {
        match: { type: 'string', short: 'e', multiple: true, default: [] },
        'ignore-case': { type: 'boolean', short: 'i', default: false },
        max: { type: 'string' },
        json: { type: 'boolean', default: false },
        help: { type: 'boolean', short: 'h', default: false },
      },
      allowPositionals: true,
    });
  } catch (err) {
    // ERR_PARSE_ARGS_UNKNOWN_OPTION, _INVALID_OPTION_VALUE, _UNEXPECTED_POSITIONAL
    if (err instanceof Error && 'code' in err && String(err.code).startsWith('ERR_PARSE_ARGS_')) {
      fail(err.message);
    }
    throw err;
  }
}

const { values, positionals } = parse();

if (values.help) {
  console.log(usage);
  process.exit(0);
}
if (positionals.length === 0) fail('at least one file is required');

let max = Infinity;
if (values.max !== undefined) {
  max = Number(values.max);
  if (!Number.isInteger(max) || max < 1) {
    fail(`--max must be a positive whole number, got '${values.max}'`);
  }
}

const fold = (s: string) => (values['ignore-case'] ? s.toLowerCase() : s);
const needles = values.match.map(fold);

const results: { file: string; count: number }[] = [];
for (const file of positionals) {
  const text = await readFile(file, 'utf8');
  const lines = text === '' ? [] : text.replace(/\n$/, '').split('\n');
  let count = 0;
  for (const line of lines) {
    if (needles.length === 0 || needles.some((n) => fold(line).includes(n))) count++;
    if (count >= max) break;
  }
  results.push({ file, count });
}

if (values.json) {
  console.log(JSON.stringify(results, null, 2));
} else {
  for (const { file, count } of results) console.log(`${String(count).padStart(7)}  ${file}`);
}

Four things in there are yours, because parseArgs has no setting for them.

The help text is a hand-written string. Nothing generates it from the options object, so the two can drift apart; keep them next to each other and update both.

--max is declared as a string, since type only accepts string or boolean. The number conversion and its error message are your code. Number('two') is NaN, which is why the check uses Number.isInteger and not a truthiness test.

“At least one file” is a manual check after parsing. There is no required key.

Errors are wrapped. With strict on (the default), bad input throws an error with one of three codes from the Node errors reference: ERR_PARSE_ARGS_UNKNOWN_OPTION, ERR_PARSE_ARGS_INVALID_OPTION_VALUE or ERR_PARSE_ARGS_UNEXPECTED_POSITIONAL. Left uncaught, a typo like --colour prints a Node stack trace. Caught, it prints the message plus usage and exits with 2, the code many Unix tools use for bad usage.

Why does parseArgs say an argument is ambiguous?

Try to count lines that contain --force:

$ node linecount.ts -e --force log.txt
linecount: Option '-e' argument is ambiguous.
Did you forget to specify the option argument for '-e'?
To specify an option argument starting with a dash use '--match=-XYZ' or '-e-XYZ'.

In strict mode, a string option’s value cannot start with - unless it is attached to the flag. The parseArgs source calls this checkOptionLikeValue; it exists so --name --verbose fails loudly instead of quietly setting name to '--verbose'. The same rule rejects --offset -5. The fix is the one in the message: --match=--force or --offset=-5. If your CLI takes negative numbers, put that in the help text.

How do you keep parseArgs types precise in TypeScript?

The @types/node definitions infer the result from the config you pass. In the CLI above, values.match is string[], values.help is boolean and values.max is string | undefined, because it has no default. positionals is typed as an empty tuple [] until you set allowPositionals: true.

That inference only works while TypeScript can see the literal config. We checked these cases with tsc --strict against @types/node 22.13 and 26.6:

import { parseArgs, type ParseArgsConfig } from 'node:util';

// Inline: values.port is string
parseArgs({ options: { port: { type: 'string', default: '3000' } } });

// Annotated: values.port is string | boolean | (string | boolean)[] | undefined
const annotated: ParseArgsConfig = { options: { port: { type: 'string', default: '3000' } } };

// as const or satisfies: values.port is string again
const config = { options: { port: { type: 'string', default: '3000' } } } satisfies ParseArgsConfig;

A plain const config = { ... } with neither fails to compile, because type widens to string. If the config lives in its own module, satisfies ParseArgsConfig is the one to reach for.

How do you handle —no-color with parseArgs?

Set allowNegative: true (added in Node 22.4.0 and 20.16.0). Then --no-color sets color to false. It only applies to boolean options: --no-name for a string option is still an unknown option. For anything else, such as a --no-logfile that clears a string, pass tokens: true and reprocess the token list, which the docs show in full. Each token carries kind, index, name, rawName, value and inlineValue.

What about subcommands?

There is no command concept, but splitting argv yourself takes a few lines:

import { parseArgs } from 'node:util';

const [command, ...rest] = process.argv.slice(2);
if (command === 'build') {
  const { values } = parseArgs({ args: rest, options: { watch: { type: 'boolean', short: 'w' } } });
  console.log('build', values.watch ?? false);
}

This breaks down once you want global flags before the command name: in tool --verbose build, command is '--verbose'. Supporting that means a first pass with tokens: true to find where the command starts, then a second parse per command.

When should you still use commander?

We use parseArgs for repo scripts, build helpers and single-purpose tools with up to about a dozen flags. With no dependency there is one less package for Dependabot to bump, and there is nothing extra to bundle if you later ship the tool as a single executable.

We switch to commander (15.0.0 as of October 2026) when a tool needs any two of: subcommands with their own help, generated --help output, required options, environment-variable fallbacks via .env() or value parsers via .argParser(). Each of those is a feature you would otherwise write, test and keep in sync with the help text yourself.

Our Node.js development work includes this kind of internal tooling: release scripts, data migration CLIs and the glue between services. If a script has outgrown its flags, we can help decide whether it needs a framework or a rewrite.

Need this built properly?

Get in touch