cheat sheet

yargs

Package-level reference for yargs on npm — command builders, middleware, prototype-pollution history, and alternatives like commander.

yargs

What it is

yargs is the long-running, declarative CLI argument parser for Node.js. You chain .command(), .option(), .middleware(), and .parse() to build a CLI that auto-generates --help, validates types, supports sub-commands, and reads from environment variables.

It powers some of the most-downloaded CLIs in the ecosystem — Mocha, Jest (historically), Webpack CLI, ESLint (historically), and many smaller tools. Its closest competitor is Commander; yargs is more declarative and feature-rich, Commander is leaner and more imperative. Both are excellent; the choice is style.

The 2020-2022 era was rough — multiple prototype-pollution CVEs flowed through yargs-parser (yargs' internal arg parser). The current 17.x line is patched and considered safe; older versions are not.

Install

bash
# npm / pnpm / yarn / bun
npm install yargs
pnpm add yargs
yarn add yargs
bun add yargs

Output: runtime dep. ~50 KB gzipped — heavyweight relative to Commander (~10 KB).

bash
# TypeScript declarations are bundled in 17+ — no separate package needed
# (for older versions, install @types/yargs)
npm install --save-dev @types/yargs    # legacy

Output: types bundled since v17. For 16 and earlier, install @types/yargs.

bash
# yargs-parser — the lower-level arg parser, used standalone
npm install yargs-parser

Output: if you only need flag parsing without help text and subcommands, yargs-parser alone is ~5 KB and faster.

bash
# yargs-interactive — interactive prompt extension
npm install yargs-interactive

Output: if a flag is missing, prompt the user (like Yeoman).

Versioning & Node support

  • Current major line is 17.x (stable since 2021 — long-lived, security-maintained). API surface unchanged for years; new minors fix bugs and refresh dependencies.
  • 17.x is ESM-first but ships dual ESM + CJS. Both import yargs from "yargs" and const yargs = require("yargs") work.
  • Pure JS; runs on Node 18+ (yargs 17.7+ dropped Node 12/14 support). Other runtimes (Bun, Deno via npm specifier) work but are not officially tested.
  • Always a runtime dependency — your CLI binary imports it.
  • Strict semver — major bumps remove deprecated subcommands and tighten parser behaviour.

Package metadata

  • Maintainer: Benjamin Coe (@bcoe), Maciej Małecki, and the yargs-js org
  • Project home: github.com/yargs/yargs
  • Docs: yargs.js.org
  • npm: npmjs.com/package/yargs
  • License: MIT
  • First released: 2010 (originally as optimist; renamed yargs in 2013)
  • Downloads: ~80 million per week

Peer dependencies & extras

PackagePurpose
yargs-parserLower-level arg parser (yargs depends on this internally; useful standalone).
@types/yargsTS declarations for yargs ≤16 (bundled in 17+).
yargs-interactiveAdds prompt-style input for missing required flags.
cliuiyargs' table-rendering layer for help output. Indirect dependency.
cosmiconfigLayered config loader — often paired with yargs to merge file-based config with CLI flags.
chalkColor the help/error output. Yargs doesn't color by default.
inquirer / prompts / enquirerHeavier interactive prompt libraries — pair with yargs for init subcommands.

Alternatives

LibraryTrade-off
commanderSmaller (~10 KB), more imperative API (.option() then .parse(process.argv)). Slightly less feature-rich but more popular in newer CLIs (vite, esbuild, …).
clipanionClass-based, TypeScript-first. Built by the Yarn team. Pick for very large CLIs with many subcommands and rich TS typing.
cittyLightweight CLI by Nuxt team. Modern, ESM-first, smaller. Pick for tiny single-purpose CLIs.
cmd-tsFunctional, TypeScript-first, type-safe argument parsing. Pick for FP-style codebases.
oclifSalesforce's heavyweight framework. Full plugin system, autoupdater, manifest generation. Pick for very large multi-binary CLI suites.
mri / minimist~1 KB raw argument parsers. No help text, no subcommands. Pick for shell-script-replacement scripts.

Common gotchas

  1. yargs(hideBin(process.argv)) is the canonical entry. Forgetting hideBin includes node/script paths in argv. The helper strips them on Node and on Windows-shim variants.
  2. .demandCommand(1) is your friend. Without it, calling the binary with no subcommand silently exits 0 — confusing UX. Always require at least one command.
  3. Type coercion is opt-in. --port 3000 is the string "3000" unless you declare .option("port", { type: "number" }). Forgetting this is the #1 footgun.
  4. .strict() is opt-in but should be default. Without .strict(), unknown flags are silently accepted into argv. .strict() makes them errors.
  5. ESM + require.main === module doesn't work. In ESM, use import.meta.url === pathToFileURL(process.argv[1]).href to detect "ran as main". Or just always invoke .parse() at the top of the file.
  6. .middleware() runs before each command's handler, including help. A middleware that calls console.log(...) will pollute --help output. Gate on argv._.length.

Real-world recipes

Basic CLI

typescript
// bin/cli.ts
import yargs from "yargs";
import { hideBin } from "yargs/helpers";

yargs(hideBin(process.argv))
  .scriptName("greet")
  .option("name", { type: "string", demandOption: true, describe: "Who to greet" })
  .option("loud", { type: "boolean", default: false, alias: "l" })
  .command("$0", "Say hello", () => {}, (argv) => {
    let msg = `Hello, ${argv.name}!`;
    if (argv.loud) msg = msg.toUpperCase();
    console.log(msg);
  })
  .strict()
  .help()
  .parse();

Output:

text
$ greet --name Alice
Hello, Alice!

$ greet --name Alice --loud
HELLO, ALICE!

$ greet
Missing required argument: name

$0 is the default command (no subcommand). .strict() + .help() are the safety net every CLI should have.

Subcommands

typescript
import yargs from "yargs";
import { hideBin } from "yargs/helpers";

yargs(hideBin(process.argv))
  .command(
    "init <path>",
    "Initialize a new project",
    (y) => y
      .positional("path", { type: "string", demandOption: true })
      .option("template", { type: "string", default: "default" }),
    (argv) => {
      console.log(`init at ${argv.path} from ${argv.template}`);
    }
  )
  .command(
    "build [target]",
    "Build the project",
    (y) => y
      .positional("target", { type: "string", default: "production" })
      .option("watch", { type: "boolean", alias: "w" }),
    (argv) => {
      console.log(`build ${argv.target} watch=${argv.watch}`);
    }
  )
  .demandCommand(1)
  .strict()
  .help()
  .parse();

Output:

text
$ mycli init ./myapp --template react
init at ./myapp from react

$ mycli build dev --watch
build dev watch=true

$ mycli
Not enough non-option arguments: got 0, need at least 1

<path> = required positional. [target] = optional positional.

Aliases + types + defaults

typescript
const argv = yargs(hideBin(process.argv))
  .option("port", { type: "number", alias: "p", default: 3000, describe: "Port to listen on" })
  .option("host", { type: "string", alias: "h", default: "localhost" })
  .option("verbose", { type: "count", alias: "v", describe: "Verbosity (-v, -vv, -vvv)" })
  .option("tag", { type: "string", array: true, describe: "Tags to apply" })
  .option("env", { type: "string", choices: ["dev", "staging", "prod"] as const, default: "dev" })
  .strict()
  .parseSync();

console.log(argv);

Output:

text
$ mycli -vvv --port 8080 --tag a --tag b --env prod
{ verbose: 3, port: 8080, host: "localhost", tag: ["a", "b"], env: "prod", _: [], $0: "mycli" }

type: "count" increments per flag (-v = 1, -vv = 2, …). array: true makes a flag repeatable. choices validates against an enum.

Environment-var config

typescript
const argv = yargs(hideBin(process.argv))
  .env("MYAPP")                                       // MYAPP_PORT → --port
  .option("port", { type: "number", default: 3000 })
  .option("api-key", { type: "string", demandOption: true })   // MYAPP_API_KEY
  .strict()
  .parseSync();

Output:

text
$ MYAPP_PORT=8080 MYAPP_API_KEY=abc mycli
# argv.port = 8080, argv.apiKey = "abc"

Environment vars are auto-mapped: MYAPP_API_KEY becomes --api-key, hyphens map to underscores. This is the canonical "twelve-factor" config layer.

Middleware — load config / authenticate / inject ctx

typescript
import yargs from "yargs";
import { hideBin } from "yargs/helpers";
import { readFileSync } from "node:fs";

interface Config { apiUrl: string; token: string }

const loadConfig = (argv: any) => {
  const cfg: Config = JSON.parse(readFileSync(argv.config ?? "./config.json", "utf8"));
  return { ...argv, config: cfg };
};

const checkAuth = (argv: any) => {
  if (!argv.config.token) throw new Error("No API token in config");
  return argv;
};

yargs(hideBin(process.argv))
  .middleware([loadConfig, checkAuth])
  .command("deploy", "Deploy the app", () => {}, (argv) => {
    console.log(`Deploying to ${argv.config.apiUrl}`);
  })
  .strict()
  .parse();

Output: middleware functions run before each command handler, in order. Use for config loading, auth, dependency injection.

Reading config files (cosmiconfig + yargs)

typescript
import { cosmiconfigSync } from "cosmiconfig";
import yargs from "yargs";
import { hideBin } from "yargs/helpers";

const cfg = cosmiconfigSync("mycli").search()?.config ?? {};

const argv = yargs(hideBin(process.argv))
  .config(cfg)                          // seed defaults from cosmiconfig
  .option("port", { type: "number", default: 3000 })
  .strict()
  .parseSync();

Output: layered config — .myclirc.json → env vars (via .env(...)) → CLI flags. Each layer overrides the previous.

Production deployment

Make the binary self-aware

json
// package.json
{
  "name": "mycli",
  "bin": { "mycli": "./dist/cli.js" },
  "scripts": {
    "build": "tsc",
    "prepublish": "npm run build"
  }
}
typescript
// dist/cli.js (compiled from src/cli.ts)
#!/usr/bin/env node
import yargs from "yargs";
// ...

The shebang lets npm install -g mycli (or npx mycli) work.

ESM + CJS dual publishing

Yargs ships dual ESM/CJS. Your CLI can match:

json
{
  "type": "module",
  "exports": {
    ".": {
      "import": "./dist/esm/cli.js",
      "require": "./dist/cjs/cli.cjs"
    }
  }
}

Or pick one — most modern CLIs are ESM-only now.

Strip yargs' source maps for size

In production, yargs is ~50 KB gzipped uncompressed. If you bundle (esbuild --bundle), tree-shaking trims further. Many CLIs prefer Commander specifically for the smaller bundle.

Performance tuning

parseSync vs parse

yargs(...).parse() returns a Promise (or argv, depending on middleware async-ness). parseSync() is sync and faster for purely-sync handlers. Most CLIs should use parseSync unless they have async middleware.

Avoid .middleware() for tiny CLIs

Each middleware adds a function call to every command. For tiny CLIs with one command, just call the helper inside the handler.

Lazy-load subcommand handlers

For huge multi-command CLIs:

typescript
yargs(...)
  .command(
    "expensive",
    "Run expensive thing",
    () => {},
    async (argv) => {
      const { runExpensive } = await import("./expensive.js");
      await runExpensive(argv);
    }
  );

This keeps startup time low — expensive.js is only loaded if that subcommand is invoked.

Version migration guide

v16 → v17

  • Node ≥ 12 required (17.7+ raised to 18). Drop older runtimes.
  • yargs/yargs import path retired. Use yargs directly.
  • ESM + CJS dual published. No code changes needed; bundlers pick the right one.
  • TypeScript types bundled. Uninstall @types/yargs after upgrade.
  • yargs-parser 21+ patched all known prototype-pollution paths. Confirm via npm ls yargs-parser.
bash
# Migration
npm install yargs@^17
npm uninstall @types/yargs
npm ls yargs-parser   # confirm ^21

Output:

text
added 1 package in 2s
removed 1 package in 1s
my-app@1.0.0
└─┬ yargs@17.7.2
  └── yargs-parser@21.1.1

Common migration breaks

typescript
// v16 — import path
import yargs from "yargs/yargs";

// v17
import yargs from "yargs";
typescript
// v16 — manually strip argv
yargs(process.argv.slice(2))

// v17 — use helper (also handles Windows shim paths)
import { hideBin } from "yargs/helpers";
yargs(hideBin(process.argv))

v17.x stability

The 17.x line has been stable for ~5 years. There's no v18 announced. Treat 17 as the LTS-style line; just stay current within it (^17).

Security considerations

Yargs' security history is real — almost every CVE has been in yargs-parser.

CVEComponentYearFixed in
CVE-2020-7608yargs-parser2020yargs-parser 18.1.2
Various PPyargs-parser2020-2022yargs-parser 21+
CVE-2021-23337(indirect via lodash)2021lodash 4.17.21

Rules:

  1. Pin yargs to ^17.7 or newer. This pulls yargs-parser ≥21 — all known prototype-pollution paths patched.
  2. npm ls yargs-parser — confirm no v18 or older copy is in your tree (some old packages pin it).
  3. Don't feed argv into deep-set or deep-merge helpers. _.set(target, argv.path, argv.value) is a classic prototype-pollution sink. Filter argv keys against an allow-list before such calls.
  4. Sensitive flags should never echo into the shell history. Read tokens/passwords via env vars or stdin prompt, not --token. Use inquirer or prompts for interactive secret input.

Testing & CI integration

typescript
import { describe, it, expect } from "vitest";
import yargs from "yargs";

const buildCli = () =>
  yargs()
    .option("port", { type: "number", default: 3000 })
    .option("host", { type: "string", default: "localhost" })
    .strict();

describe("cli", () => {
  it("parses port + host", () => {
    const argv = buildCli().parseSync(["--port", "8080", "--host", "0.0.0.0"]);
    expect(argv.port).toBe(8080);
    expect(argv.host).toBe("0.0.0.0");
  });

  it("rejects unknown flags in strict mode", () => {
    expect(() => buildCli().parseSync(["--unknown"])).toThrow();
  });
});

Output: yargs accepts an explicit argv array — test without spawning a subprocess.

For integration tests with real binaries, use execa:

typescript
import { execa } from "execa";
const { stdout } = await execa("node", ["./dist/cli.js", "--port", "8080"]);

Ecosystem integrations

ToolIntegration
cosmiconfigLayered config — .myclirc.json, package.json field, env var, CLI flag
dotenv.env file → process.envyargs.env(...)
chalkColor help output via yargs.usage(chalk.blue(...))
cliuiyargs' table renderer — exported standalone for custom output
pino / winstonLogger initialized from argv.verbose count
inquirer / promptsInteractive prompts after yargs argv parse for missing inputs
update-notifierNotify users when a newer version of the CLI is available
enquirerAlternative interactive prompts

Troubleshooting common errors

  • Missing required argument: <name>demandOption: true flag not supplied. Either supply it, set default:, or remove demandOption.
  • Unknown argument: <name> in .strict() mode — flag not declared. Add an .option() call or remove .strict().
  • argv.port is "3000" (string) instead of 3000 (number) — forgot type: "number".
  • process.argv includes node and the script path — use hideBin(process.argv), not process.argv directly.
  • Cannot find module 'yargs' under tsc — ensure "moduleResolution": "node16" or "bundler" so the conditional exports map resolves.
  • yargs is not a function — usually import * as yargs from "yargs" instead of default. Use import yargs from "yargs".
  • Sub-command help isn't shown — missing .help() at the top level, or you're calling the handler before .parse().

When NOT to use this

  • You only need flag parsing, no help/subcommands. Use minimist or mri — ~1 KB, ten lines of code.
  • You prefer imperative chaining. Use commander — smaller, more popular in newer tools, slightly easier to write.
  • You want type-safe argv at compile time. Use cmd-ts or clipanion — typed all the way through.
  • You're building a massive plugin-driven CLI suite. Use oclif — has plugin manifest, autoupdater, manifest generation built in.
  • You're shell-scripting in Node. Use zx or execa — they handle args differently (you're not building a CLI for others; you're scripting).

See also