
The 40kb Tax for a 10-Line Script: My Journey to a Zero-Dependency CLI with Node’s util.parseArgs
I finally audited my tiny automation scripts and realized I was carrying a mountain of library bloat just to parse a few flags—here is how I moved to a native, type-safe solution.
I have this habit of checking the disk size of my "micro" automation scripts, and last Tuesday, I realized I was paying a significant tax—both in disk space and mental overhead—just to tell a 10-line script to run in "dry-run" mode. My package.json was a graveyard of yargs, commander, and minimist, all pulling in dozens of sub-dependencies just to parse a few strings from process.argv.
It felt like hiring a construction crew to hang a single picture frame.
For years, we didn't have a choice. Node.js famously lacked a robust, built-in way to handle CLI arguments. You either did some brittle regex on process.argv.slice(2) or you reached for the heavy hitters. But since Node.js v18.3.0 (and backported to later v16 versions), we’ve had util.parseArgs. It’s stable, it’s fast, and it’s already sitting on your machine.
The Bloat We’ve Accepted
Before we dive into the solution, look at what we usually do. Even a "minimal" CLI setup with a popular library often looks like this:
$ npm install yargs
# ... 40 packages added, 1MB of node_modules created ...Then the code:
// script.js
const argv = require('yargs/yargs')(process.argv.slice(2))
.option('output', { type: 'string' })
.option('verbose', { type: 'boolean' })
.parse();
console.log(argv.output);It works fine, but now your tiny utility script requires a package.json, an npm install step for every CI environment, and regular security audits. For a script that moves some files around? No thanks.
The Lean Alternative: util.parseArgs
Here is the same functionality using the native util module. No installs, no node_modules, just pure Node.
import { parseArgs } from 'node:util';
const options = {
output: {
type: 'string',
short: 'o',
},
verbose: {
type: 'boolean',
short: 'v',
default: false,
},
};
const { values, positionals } = parseArgs({ options });
console.log(values);
// Run with: node script.js -o ./dist --verbose
// Result: { output: './dist', verbose: true }The configuration object is straightforward. You define your flags, specify if they expect a value (string) or act as a toggle (boolean), and even give them short aliases.
Why I switched (The "Why")
The biggest win isn't just the 40kb of saved space. It’s the portability. I can now send a single .js or .mjs file to a teammate, and as long as they have a modern version of Node, it just runs. No npm install friction.
But there are technical perks too:
1. Strict by default: If a user passes an undefined flag (like --help when you haven't defined it), parseArgs throws an error. This prevents silent failures.
2. The `--` separator: It correctly handles the "end of options" signal. Everything after -- is automatically dumped into the positionals array.
3. Type Safety: If you're using TypeScript, parseArgs provides excellent type inference out of the box.
Handling Positionals
Most scripts don't just use flags; they take raw arguments, like file paths. In the old days, we’d guess the index of process.argv. With parseArgs, it’s handled for us.
import { parseArgs } from 'node:util';
const { values, positionals } = parseArgs({
allowPositionals: true,
options: {
force: { type: 'boolean' }
}
});
// Run with: node rename.js --force file1.txt file2.txt
console.log('Positionals:', positionals); // ['file1.txt', 'file2.txt']
console.log('Force:', values.force); // trueLet's talk about TypeScript
One of the pleasant surprises was how well parseArgs plays with TypeScript. You don't need to define a complex interface for your parsed results; Node's types can often infer the shape of the values object based on your options definition.
import { parseArgs } from 'node:util';
const config = {
options: {
username: { type: 'string' as const },
retries: { type: 'string' as const }, // Note: everything from CLI is a string initially
},
} as const;
const { values } = parseArgs(config);
// values.username is inferred as string | undefined
// values.retries is inferred as string | undefined*Quick tip:* Using as const on your configuration object is the secret sauce to getting perfect inference. Without it, TypeScript might be a bit too generic.
The Gotchas
It’s not all sunshine. util.parseArgs is intentionally low-level. It doesn't:
- Auto-generate a --help menu (you have to write the string yourself).
- Support "required" arguments (you'll need a simple if (!values.name) throw ... check).
- Coerce strings into numbers (you’ll need to Number(values.port) yourself).
To me, these aren't dealbreakers. They are just the reality of writing clean, dependency-free code. I’d much rather write a five-line printHelp() function than pull in a library that includes a full-blown CLI framework.
The Zero-Dependency Zen
Deleting node_modules from an automation folder is a peculiar kind of therapy. By moving to util.parseArgs, my scripts started feeling like tools again rather than projects. They are faster to start, easier to maintain, and they don't contribute to the "dependency hell" we’ve all grown accustomed to.
If your script is under 500 lines and only has a few flags, do yourself a favor: audit your dependencies and see if you can trade that 40kb tax for a few lines of native code. Your future self—and your disk space—will thank you.


