I’ve written quite a few command-line tools, whether for public consumption or at work. They often take the form of a script that does one thing, but then needs some flags to make it truly useful. At some point you probably want to pass a list of files to even the most basic tool you write!

Command-line argument parsing is one of those tasks that everyone will tell you is solved: just grab a library! But there’s a cost to “just grab a library”.

There are two core costs:

  • Initial learning curve: I may know exactly what I want to get done. How does this library work, and will it satisfy what needs to be done? Does it fit into my constraints and requirements? How long will it take me to figure all of this out?
  • Maintenance: the library will change and evolve, or it will die. How much effort will it take to update or replace it?

Obstacles I’ve Encountered

I’ve found that these two costs often completely outweigh the benefits, especially for the very specific task of parsing command line arguments.

Some of them aren’t written in TypeScript (or the types don’t match the implementation), so they’re not type safe, at exactly the time when you want to be sure of data structures: when dealing with user input! This is a big one.

Some of them are written in TypeScript, but due to the varied nature of argument parsing, become so complicated that you end up defining more types or structures than you actually need.

Encoding relationships between flags and commands in a generic library is really hard. An example would be two flags that are incompatible with each other. Figuring out how a library has encoded this is usually more work than just doing it manually!

Some of them try to help automate common tasks like outputting generated --help. This is helpful! But they often require a specific string-based format to define the arguments, description, and usage. This is usually pretty hard to make type-safe as well.

The Bare Minimum

What I usually end up doing is just writing my own. Using an example of a music player, I’ve found that I basically need the same things:

The concrete shape of the parsed data, ready for the logic later:

type ParsedCLIFlags = {
  // a command to give the player
  command: "play" | "pause";

  // some sort of identifier, like a link
  track: string;

  // where to seek in seconds, to skip ahead or back up
  seek: number;
};

A function to output the parsed shape:

function parseCLI(): ParsedCLIFlags {
  const trackIdx = process.argv.indexOf("--track");
  if (trackIdx === -1) throw new Error("No --track passed!");
  const track = process.argv[trackIdx + 1];

  const seekIdx = process.argv.indexOf("--seek");
  const seek = seekIdx > -1 ? Number(process.argv[seekIdx + 1]) : 0;

  const command =
    process.argv.indexOf("play") > -1
      ? "play"
      : process.argv.indexOf("pause") > -1
      ? "pause"
      : undefined;
  if (command === undefined)
    throw new Error("Invalid command, expected pause or play!");

  return {
    command,
    track,
    seek
  };
}

Usually something to output help:

const showHelpAndExit = () => {
  console.log(`
Usage
  $ player <command> [options]
Commands
  play
  pause
Global Options
  --track [URL]                       The track to play
  --seek, -s [0.0]                    Where to start playing from.
  --help, -h                          Display this help.
Examples
  # Play a track
  $ player play --track https://www.youtube.com/watch?v=-2sVzixucQU
`);
  process.exit(1);
};

And then a main function:

async function run() {
  if (process.argv.indexOf("--help") > -1) {
    return showHelpAndExit();
  }

  let parsed: ParsedCLIFlags;
  try {
    parsed = parseCLI();
  } catch (e) {
    console.log(e);
    return showHelpAndExit();
  }

  // And finally, do something with `parsed`!

  switch (parsed.command) {
    case "play":
      // ...
      break;

    case "pause":
      // ...
      break;
  }
}

run();

You Still Have to Answer the Same Questions

If I’d used a library for this example, I’d have to answer all of these questions, regardless:

  • Deciding what logic to apply to a “command” and to a “flag”.
  • Which combinations are valid, which are not.
  • How to handle errors? Throw? When? Return a parsed result that you have to check?
  • If arguments are invalid, should I exit the process automatically? Does it output “help” when it exits?

Digging through examples and documentation (or lack thereof) for answers gets me down when I could instead just choose appropriately for the specific situation at hand.

Real World Example

If you want a real-world example, check out this CLI I updated recently. I went to update its previous CLI parser to a new major version, only to find it had TypeScript limitations. It has a small utility function to allow for short names, defaults, and type coercion.

An even simpler one is for a tool I wrote a few months ago called Idier. Its CLI parsing is a single function that handles exiting, help, and validation.

But It Depends

Testing is one issue I haven’t addressed, and it is hard to argue that some hand-rolled code is better than open source code with excellent code coverage and used by thousands of developers.

But it depends on your use case. Sometimes, the simplicity of the code you can write (and will test anyway with integration) outweighs the cognitive overhead of “just use a library”.

Addendum for Those That Are Fancy

If you want to be a little more fancy, here are some additional functions. They operate by assuming there is always a valid default. If you wanted them to throw instead, that’s fairly simple to copy/paste. :)

function keyValueArgv<T>(argv: string[], key: string, defaultValue: T) {
  const idx = argv.indexOf(key);
  if (idx === -1) return defaultValue;
  if (idx + 1 > argv.length) return defaultValue;
  const value = argv[idx + 1];
  if (!value) return defaultValue;
  return value;
}

function boolArgv(argv: string[], key: string) {
  const idx = argv.indexOf(key);
  if (idx === -1) return false;
  return true;
}

function restArgv<T>(argv: string[], defaultValue: T) {
  const isFlag = (v: string) => v.indexOf('--') === 0;
  const rest = argv.filter((arg, idx) => {
    return !isFlag(arg) && (idx - 1 >= 0 ? !isFlag(argv[idx - 1]) : true);
  });
  if (rest.length === 0) return defaultValue;
  return rest;
}

Usage:

// Copy all the argvs
const programArgv = process.argv.slice(2);

// Resolve all rest args as absolute files
const files = restArgv(programArgv, [
  path.join(process.cwd(), 'src'),
]).map(p => path.resolve(p));

// Default output dir is a docs/ dir
const outputDir = keyValueArgv(
  programArgv,
  '--output',
  path.join(process.cwd(), 'docs/'),
);

// Some sort of config flag
const config1 = keyValueArgv(programArgv, '--config1', `the default`);

// Presence means true
const showHelp = boolArgv(programArgv, '--help');