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"}
}