yargs
yargs parses command line arguments into an object and builds the interactive parts of a CLI around that: subcommands with their own options, an auto-generated help screen, validation, and Bash/Zsh completion scripts. You chain configuration calls (.command(), .option(), .demandCommand(), .strict()) on a parser instance and call .parse() to get argv. It has been one of the two default Node argument parsers for over a decade (mocha is built on it), and version 18 made it ESM-only with a Node 20.19+ floor.
Feature-wise still the most complete argument parser on npm, and the right call for complex ESM CLIs that want completion and config layering. For CJS projects, small tools, or type-strict teams, commander or the built-in parseArgs gets you there with less baggage.
Use it if
- You are building a multi-command CLI (tool serve, tool build) and want per-command options, positional arguments, and grouped help generated for you
- You want parsing conveniences out of the box: type coercion, --no-flag negation, camelCase aliases, array options, and count options like -vvv
- You need shell completion: .completion() generates Bash and Zsh scripts, which commander does not do without a plugin
- You want configuration layering: .env() prefixed environment variables, .config() JSON files, and defaults all merge into one argv with a documented precedence
- Your codebase is CommonJS: v18 is ESM-only (every entry point is .mjs with no require path), so require('yargs') pins you to v17, which is now the legacy line
- You are parsing a handful of flags: node:util parseArgs ships inside Node and costs zero dependencies, while yargs brings six runtime packages
- You care about install footprint or bundling a single-file CLI: commander and cac have zero dependencies and bundled TypeScript types; yargs types live in a separately versioned @types/yargs that regularly lags
- You want strict typing of commands and options: the builder-callback typing in @types/yargs is famously awkward, and 231 open issues and PRs include long-standing type complaints
Setup reality
npm install yargs, then import yargs from 'yargs' and hideBin from 'yargs/helpers'; the yargs(hideBin(process.argv)) incantation is boilerplate everyone copies. On v18 your package must be ESM or you must use dynamic import; there is no CJS build. TypeScript users install @types/yargs separately and discover that typing .command() builders properly takes real effort. Node below 20.19 refuses to run it, which matters on older CI images. The docs are markdown files in the repo, and some examples still show v17 idioms.
Patterns
Parse flags into argvbasic-parse
#!/usr/bin/env node
import yargs from 'yargs'
import { hideBin } from 'yargs/helpers'
const argv = yargs(hideBin(process.argv)).parse()
// ./tool.js --port 8080 --verbose -> { port: 8080, verbose: true }hideBin strips the node binary and script path and handles Electron quirks. Values that look numeric are coerced to numbers automatically.
Declare typed options with validationdefine-options
const argv = yargs(hideBin(process.argv))
.option('env', {
alias: 'e',
type: 'string',
choices: ['dev', 'staging', 'prod'],
default: 'dev',
describe: 'target environment'
})
.option('force', { type: 'boolean', default: false })
.parse()Dashed flags appear twice in argv: --dry-run gives both argv['dry-run'] and argv.dryRun. choices rejects bad values before your code runs.
Build git-style subcommandssubcommands
yargs(hideBin(process.argv))
.command(
'serve [port]',
'start the server',
(yargs) => yargs.positional('port', { describe: 'port to bind', default: 5000 }),
(argv) => serve(argv.port)
)
.command('build', 'compile the project', {}, (argv) => build(argv))
.demandCommand(1, 'pick a command')
.parse()Without demandCommand, running with no command silently does nothing; the second argument is the error message users see.
Reject typos and unknown flagsstrict-mode
yargs(hideBin(process.argv))
.command('deploy', 'ship it', {}, deploy)
.strict() // unknown commands AND options error out
.strictOptions() // or: only unknown options
.parse()Off by default, so --verbos (typo) is accepted silently unless you call strict(); nearly every real CLI wants this.
Use async handlers correctlyasync-command-handler
await yargs(hideBin(process.argv))
.command('migrate', 'run migrations', {}, async (argv) => {
await runMigrations(argv)
})
.parseAsync()Use parseAsync() when any handler returns a promise; plain parse() returns before async handlers settle, so rejections can escape as unhandled.
Read options from environment variablesenv-variables
yargs(hideBin(process.argv))
.env('MYAPP') // MYAPP_API_KEY -> argv.apiKey
.option('api-key', { type: 'string', demandOption: true })
.parse()Precedence is command line over env over config file over defaults; demandOption is satisfied by the env var, which is what you want in CI.
Load options from a JSON config fileconfig-file
yargs(hideBin(process.argv))
.config('settings') // --settings ./deploy.json merges its keys into argv
.option('region', { type: 'string', default: 'us-east-1' })
.parse()Keys from the file are treated like typed options; explicit command line flags still win over file values.
Normalize argv before handlers runmiddleware
yargs(hideBin(process.argv))
.middleware((argv) => {
if (argv.verbose) process.env.LOG_LEVEL = 'debug'
argv.startedAt = Date.now()
})
.command('run', 'run the thing', {}, (argv) => run(argv))
.parse()Middleware runs after validation by default; pass true as the second argument to run it before validation (for example to fill required options).
Generate Bash/Zsh completionshell-completion
yargs(hideBin(process.argv))
.command('serve', 'start server', {}, serve)
.completion() // adds a hidden `completion` command
.parse()
// user runs: ./tool completion >> ~/.bashrcCompletion covers command and flag names automatically; for dynamic values provide a function to completion(cmd, describe, fn).
Control error output and exit behaviorfail-handler
yargs(hideBin(process.argv))
.command('run', 'run', {}, run)
.fail((msg, err, yargs) => {
if (err) throw err // real exceptions propagate
console.error(yargs.help())
console.error('\nError:', msg)
process.exit(64)
})
.parse()Default behavior prints the message and exits 1. Distinguish msg (validation failure) from err (thrown in a handler) or you will swallow stack traces.
Test parsing without exiting the processtesting-a-cli
import { buildParser } from '../src/cli.js'
test('serve parses port', async () => {
const argv = await buildParser()
.exitProcess(false)
.parseAsync(['serve', '--port', '3000'])
expect(argv.port).toBe(3000)
})exitProcess(false) makes failures throw instead of calling process.exit; export a factory that builds the parser so tests and the bin script share it.
Alternatives
| Package | Registry | Pick it when |
|---|---|---|
| commander | npm | You want the same command-and-option feature set with zero dependencies, bundled types, and CJS plus ESM support. |
| cac | npm | You want a tiny dependency-free parser with a commander-like API for small-to-medium CLIs. |
| citty | npm | You are in the unjs/Nitro ecosystem and want declarative command definitions with lazy-loaded subcommands. |