CLI#

CLI plumbing is the JavaScript surface (see CLI) plus typed option structures. The default in TypeScript is commander (mature, widely used) or citty (typed-first, ESM-native). For stdlib-only scripts, util.parseArgs plus a hand-written options interface works.

This page covers the TypeScript-specific patterns. For the underlying process.argv mechanics, see the JavaScript page.

Run TypeScript directly#

Run .ts files without a compile step using tsx, ts-node, deno, or bun.

$ npx tsx script.ts
$ deno run script.ts
$ bun run script.ts

tsx is the most widely deployed in 2026; it loads TypeScript and ESM transparently through Node’s loader hooks.

parseArgs with a typed structure#

node:util.parseArgs is stdlib-only; the operator gives the options structure a type so misuse trips at the call site.

import {parseArgs} from "node:util";

const {values, positionals} = parseArgs({
  options: {
    verbose: {type: "boolean", short: "v"},
    port:    {type: "string",  short: "p", default: "80"},
  },
  allowPositionals: true,
});

const port = Number(values.port);          // string -> number narrowing

The compiler infers values as {verbose?: boolean; port?: string}. For typed coercion (string -> number, string -> literal union), wrap the parsed object in a zod schema.

const Opts = z.object({
  verbose: z.boolean().default(false),
  port:    z.coerce.number().int().positive().default(80),
});
const opts = Opts.parse(values);
//    ^? { verbose: boolean; port: number }

commander#

commander is the default for non-trivial CLIs. Subcommands, type coercion, --help, --version.

$ npm install commander
import {Command} from "commander";

interface Opts {
  port: number;
  verbose?: boolean;
}

const program = new Command()
  .name("scan")
  .description("Quick port scanner.")
  .argument("<host>", "Target host.")
  .option("-p, --port <number>", "Port to probe.", Number, 80)
  .option("-v, --verbose", "Verbose output.")
  .action((host: string, opts: Opts) => {
    console.log({host, ...opts});
  });

program.parse();

citty (typed-first)#

citty is go-tos when typed options matter; the options object drives both parsing and the runtime args structure.

$ npm install citty
import {defineCommand, runMain} from "citty";

const main = defineCommand({
  meta: {name: "scan", version: "0.1.0"},
  args: {
    host: {type: "positional", description: "Target host."},
    port: {type: "string", default: "80"},
    verbose: {type: "boolean", alias: "v"},
  },
  async run({args}) {
    const port = Number(args.port);
    console.log({host: args.host, port, verbose: args.verbose});
  },
});

runMain(main);

args carries types derived from the args config; no casting needed.

Exit codes#

process.exit(code) exits with that code. Convention: 0 on success, small positive integer on failure. Prefer setting process.exitCode and letting the event loop drain naturally.

if (!ok) {
  console.error(`error: ${err.message}`);
  process.exitCode = 1;
  return;
}

Streaming#

Filter-style scripts read stdin and write stdout. readline/promises gives an async iterator.

import {createInterface} from "node:readline/promises";

for await (const line of createInterface({input: process.stdin})) {
  process.stdout.write(line.toUpperCase() + "\n");
}

Shebang#

Add a shebang and chmod +x after compilation; tsx works the same way on a .ts source.

#!/usr/bin/env -S npx tsx
console.log("hello,", process.argv[2] ?? "world");

For shipped CLIs, declare a bin entry in package.json; the runtime entry must be plain JavaScript (compiled with tsc or bundled with tsup).

{
  "name": "myscan",
  "type": "module",
  "bin": {"scan": "./dist/cli.js"}
}

References#