An argv Parser Without yargs or commander
Eighty percent of the time I just need --flag and --opt=value parsing for a one-off script. Adding yargs feels heavy. This is the 30-line zero-dependency parser I copy into every Node script.
By @nehanasser
May 1, 2026
·
Updated August 19, 2026
461 views
8
4.4 (12)
// parseArgs(argv) returns { flags: Set, options: {}, positional: [] }.
// Supports: --flag, --no-flag, --opt=value, --opt value, -short=v, -short v.
// Stops parsing after a literal '--', the rest is positional.
function parseArgs(argv) {
const flags = new Set();
const options = {};
const positional = [];
let i = 0;
while (i < argv.length) {
const arg = argv[i];
if (arg === '--') { positional.push(...argv.slice(i + 1)); break; }
if (arg.startsWith('--')) {
const body = arg.slice(2);
const eq = body.indexOf('=');
if (eq !== -1) {
options[body.slice(0, eq)] = body.slice(eq + 1);
} else if (body.startsWith('no-')) {
flags.add(body.slice(3)); options[body.slice(3)] = false;
} else if (i + 1 < argv.length && !argv[i + 1].startsWith('-')) {
options[body] = argv[++i];
} else {
flags.add(body); options[body] = true;
}
} else if (arg.startsWith('-') && arg.length > 1) {
const body = arg.slice(1);
const eq = body.indexOf('=');
if (eq !== -1) {
options[body.slice(0, eq)] = body.slice(eq + 1);
} else if (i + 1 < argv.length && !argv[i + 1].startsWith('-')) {
options[body] = argv[++i];
} else {
flags.add(body); options[body] = true;
}
} else {
positional.push(arg);
}
i++;
}
return { flags, options, positional };
}
const sample = ['--verbose', '--out=build.json', '-n', '10', 'input.txt', '--', '--not-a-flag'];
const parsed = parseArgs(sample);
console.log({
flags: [...parsed.flags],
options: parsed.options,
positional: parsed.positional,
});The parser handles the four shapes I actually use: bare --flag, --key=value, --key value, and --no-flag for negation. The -- sentinel is critical for any script that forwards args to a child process (think npm run start -- --port=3000); without it, downstream tools see your script's flag parser eating their flags. The positional collection at the end keeps trailing arguments simple: anything that does not look like a flag, or anything after --, is positional. I deliberately do not support clumped short flags (-abc meaning -a -b -c) because it is rarely worth the parser complexity.
// Raw parseArgs returns strings. Wrap it with a schema-driven layer that
// coerces types and reports missing required args in one error block.
function parseArgs(argv) {
const options = {}, positional = [];
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg.startsWith('--')) {
const eq = arg.indexOf('=');
if (eq !== -1) options[arg.slice(2, eq)] = arg.slice(eq + 1);
else if (i + 1 < argv.length && !argv[i + 1].startsWith('-')) options[arg.slice(2)] = argv[++i];
else options[arg.slice(2)] = true;
} else if (arg.startsWith('-') && arg.length > 1) {
if (i + 1 < argv.length && !argv[i + 1].startsWith('-')) options[arg.slice(1)] = argv[++i];
else options[arg.slice(1)] = true;
} else positional.push(arg);
}
return { options, positional };
}
function defineCli(schema) {
return (argv) => {
const { options, positional } = parseArgs(argv);
const errors = [], out = { _: positional };
for (const [k, def] of Object.entries(schema)) {
const raw = options[k];
if (raw === undefined) { def.required ? errors.push(`missing --${k}`) : (out[k] = def.default); continue; }
if (def.type === 'number') { const n = Number(raw); Number.isNaN(n) ? errors.push(`--${k}: NaN`) : (out[k] = n); }
else if (def.type === 'boolean') out[k] = raw === true || raw === 'true' || raw === '1';
else out[k] = String(raw);
}
if (errors.length) { console.error('Errors:\n ' + errors.join('\n ')); return null; }
return out;
};
}
const run = defineCli({
out: { type: 'string', required: true },
workers: { type: 'number', default: 4 },
verbose: { type: 'boolean', default: false },
});
// Footgun: `parseArgs` greedily consumes the next non-dash token as a value,
// so positionals must come BEFORE bare flags. '--verbose src/index.js' parses
// as options.verbose='src/index.js'. Use --verbose=true if you must put it earlier.
console.log(run(['--out=build.json', 'src/index.js', '--verbose']));
console.log(run(['--workers', '8']));The schema layer is what turns raw key/value pairs into a typed argument object. Required-and-missing args are collected into one error block rather than crashing on the first one, so the user sees every problem in a single run; this is exactly the UX yargs and clap give you and is worth keeping. I cap this at three types (string, number, boolean) because anything more elaborate (arrays of, comma-separated lists) belongs in a real arg-parser library; if you find yourself adding a fourth type, that is the signal to switch. One footgun worth flagging: parseArgs greedily consumes the next non-dash token as the option's value, so positionals must come BEFORE bare boolean flags in the argv stream (or you spell the flag explicitly as --verbose=true).
// `git commit`, `npm run build`, `kubectl apply`: subcommands. The pattern is
// to take argv[0] as the subcommand name, look it up in a dispatch table, and
// pass the rest to that handler's parser. (parseArgs reused from acc 1 in
// compressed form.)
function parseArgs(argv) {
const options = {}, positional = [];
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg.startsWith('--')) {
const eq = arg.indexOf('=');
if (eq !== -1) options[arg.slice(2, eq)] = arg.slice(eq + 1);
else if (i + 1 < argv.length && !argv[i + 1].startsWith('-')) options[arg.slice(2)] = argv[++i];
else options[arg.slice(2)] = true;
} else if (arg.startsWith('-') && arg.length > 1) {
if (i + 1 < argv.length && !argv[i + 1].startsWith('-')) options[arg.slice(1)] = argv[++i];
else options[arg.slice(1)] = true;
} else positional.push(arg);
}
return { options, positional };
}
const commands = {
build: (argv) => {
const { options, positional } = parseArgs(argv);
console.log(`build target=${positional[0] || 'default'} watch=${!!options.watch}`);
},
test: (argv) => {
const { options } = parseArgs(argv);
console.log(`test filter=${options.filter || '(all)'} verbose=${!!options.verbose}`);
},
help: () => console.log('Usage: tool <command> [...args]\nCommands: build, test, help'),
};
function main(argv) {
const [name, ...rest] = argv;
const handler = commands[name] || commands.help;
return handler(rest || []);
}
main(['build', 'lib', '--watch']);
main(['test', '--filter=login']);
main(['help']);
main(['unknown-cmd']);Subcommands are just a string switch over argv[0]. The trick that keeps the code readable is to give every handler the same signature (rest) => void so the dispatcher does not need to know anything about each subcommand's args. The fallback to commands.help for unknown commands is what makes the tool feel polished; the unknown branch normally collapses into the help branch, which is a one-line change rather than a separate error path. I have shipped this exact shape for two internal CLIs; the moment a subcommand grows nested subcommands, you replace commands with a tree and recurse, which is still less code than yargs' command builder API.
