Skip to content

Building a CLI Tool in Node.js With TypeScript in 2026

From Zero to a Publishable Node.js CLI Tool

A complete walkthrough of building a Node.js CLI with TypeScript: argument parsing, exit codes, testing, and publishing without ts-node.

· · 4 min read

Quick Take

Every internal script I'd written started as a quick node script.js and grew arms and legs until it needed real argument parsing. I finally built the version I should have started with, and it took less setup than I expected.

The script that prompted this was a data-migration tool that had grown three undocumented flags, no help text, and a habit of exiting with code 0 even when it failed halfway through. None of that was intentional, it just accumulated because the script never got the fifteen minutes of structure a real CLI needs from the start.

TL;DR: A real Node.js CLI needs four things: argument parsing (util.parseArgs() for simple cases, Commander for subcommands), meaningful exit codes (0 for success, non-zero for every failure path, no exceptions), a bin entry in package.json with a shebang line so it runs as a real command, and tests that don't require the whole tool to actually execute against real systems.

Argument Parsing With util.parseArgs()

#!/usr/bin/env node
import { parseArgs } from 'node:util';

const { values, positionals } = parseArgs({
  options: {
    output: { type: 'string', short: 'o', default: './dist' },
    verbose: { type: 'boolean', short: 'v', default: false },
    force: { type: 'boolean', default: false },
  },
  allowPositionals: true,
});

const [inputFile] = positionals;

if (!inputFile) {
  console.error('Usage: mycli <input-file> [--output <dir>] [--verbose] [--force]');
  process.exit(1);
}

if (values.verbose) {
  console.log(`Processing ${inputFile} -> ${values.output}`);
}

parseArgs() handles short and long flags, typed values (string/boolean), and defaults, all without a dependency. For a CLI with a flat set of flags and no subcommands, this covers the whole surface a heavier library like Commander would otherwise be pulled in for.

Exit Codes That Mean Something

async function run(): Promise<void> {
  try {
    const inputFile = positionals[0];
    if (!inputFile) {
      console.error('Error: input file required');
      process.exit(1);
    }

    await validateFile(inputFile); // throws if the file doesn't exist or is malformed
    await processFile(inputFile, values.output as string);

    console.log('Done.');
    process.exit(0);
  } catch (error) {
    console.error(`Error: ${error instanceof Error ? error.message : String(error)}`);
    process.exit(1);
  }
}

run();

The try/catch around the whole run function is what stops an uncaught exception from producing Node's default stack trace dump with an ambiguous exit code, or worse, exiting 0 despite the failure. A CI pipeline or shell script calling this tool needs $? (the exit code) to be trustworthy, if my-cli input.json; then echo ok; fi only works correctly if a real failure always produces a non-zero code.

TypeScript Without a Build Step, Using Native Type Stripping

// package.json
{
  "name": "mycli",
  "version": "1.0.0",
  "type": "module",
  "bin": {
    "mycli": "./src/index.ts"
  },
  "engines": {
    "node": ">=23.6.0"
  }
}
// src/index.ts
#!/usr/bin/env node
// ... the CLI code above

As of Node 23.6+, Node runs .ts files directly via native type stripping, no ts-node, no tsx, no build step, as long as the TypeScript doesn't rely on features requiring actual transformation (enums, namespaces, without the extra --experimental-transform-types flag). For a CLI targeting a Node version that new, this removes an entire category of "works on my machine" build tooling drift. For broader compatibility with older Node versions, compile with tsc to plain .js and point bin at the compiled output instead.

Publishing It

npm link          # test locally: creates a global symlink to your local package
mycli --help       # now runs as a real command

npm publish        # ships it to the npm registry, once bin/shebang/package.json are set

npm link is the step worth doing before a real publish, it exercises the exact bin-to-PATH symlink mechanism a real install will use, catching a missing shebang or incorrect file permissions before they become a bug report from an actual user.

Testing Without Actually Running the CLI End-to-End

import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { validateFile } from '../src/validate.js';

describe('validateFile', () => {
  it('rejects a missing file', async () => {
    await assert.rejects(() => validateFile('/nonexistent.json'));
  });
});

The parsing and business logic, validateFile, processFile, live in their own exported functions, tested directly with node --test, separate from the parseArgs()/process.exit() wiring in index.ts. Testing the wiring itself (does --force actually get read correctly) is worth a handful of integration tests that spawn the CLI as a subprocess and check its exit code and output, but the bulk of the logic should be testable without spawning a process at all.

Conclusion

A CLI tool earns real structure the moment more than one person, including future you, has to run it without reading the source first. Argument parsing, meaningful exit codes, and a proper bin entry are the three pieces that turn a node script.js habit into something that behaves like every other command a user already trusts.

Frequently Asked Questions

Do I need a library like Commander or yargs to build a CLI in 2026?
For a simple CLI with a handful of flags, no. Node's built-in util.parseArgs(), stable since Node 20, handles flag parsing, defaults, and required-value validation without a dependency. Reach for Commander or yargs once you need subcommands with their own distinct flag sets (git commit vs git push style), auto-generated help text, or shell completion, features parseArgs() intentionally doesn't cover.
What exit code should a CLI tool use for errors?
0 for success, and a non-zero code for failure, by convention 1 for a generic error. Some tools use more specific codes (2 for a usage error, 3 for a specific known failure category) so calling scripts and CI pipelines can branch on the failure type without parsing output text. Whatever scheme you pick, document it, and never let an uncaught exception exit with code 0, that silently tells calling scripts the tool succeeded when it didn't.
How do I make my TypeScript CLI runnable directly as a command after npm install?
Set the bin field in package.json to point at your compiled entry file, and make sure that file starts with a #!/usr/bin/env node shebang line. After npm install -g (or npm link during development), npm creates a symlink in the user's PATH pointing at that file, so typing your command name runs it directly, provided the file also has execute permissions, which npm sets automatically from the bin field.