Building a Typed CLI
Expert · 20 min read · ▶ live playground · ✦ checkpoint
A typed CLI turns terminal words into a command shape before work starts. TypeScript helps after it; it cannot trust process.argv, prompt answers, or metadata by annotation alone.
A CLI is a command-line interface: a program people run from a terminal. A flag is a named option such as --plan pro, and a positional argument is a value whose meaning comes from position, such as the launch name in launch-plan create "Search launch".
CLI input is like request bodies, env vars, and raw SQL rows from Section 23. Strings come in. Validation hands the rest of the program a receipt.
Parse into a command shape
An argument parser is code that turns raw terminal strings into named values. Real Node CLIs read process.argv; this playground uses string[] so it can run honestly here.
→ draft Search launch for pro plan needs 10 karma
→ exit 0
→ invalid --plan; use free or pro
→ exit 2
The important line is runLaunch(options: LaunchOptions). The command logic receives a narrow set: plan is "free" | "pro", karmaRequired is a number, and dryRun is boolean. Everything before that is boundary work.
Commander and citty at the shell edge
Commander and citty are CLI libraries that describe commands, flags, help text, and handlers. Commander uses a fluent builder. Citty uses defineCommand with typed argument definitions. Both are read-only reference code because the packages are not installed in the playground.
Read-only reference code:
import { Command } from "commander";
import { defineCommand, runMain } from "citty";
type Plan = "free" | "pro";
function parsePlan(value: string): Plan | null {
return value === "free" || value === "pro" ? value : null;
}
new Command()
.name("launch-plan")
.argument("<name>", "launch name")
.option("--plan <plan>", "free or pro", "free")
.option("--dry-run", "print without creating")
.action((name: string, raw: { plan: string; dryRun?: boolean }) => {
const plan = parsePlan(raw.plan);
if (plan === null) {
return;
}
console.log(raw.dryRun === true ? "Drafting " + name : "Creating " + name);
});
const cittyCommand = defineCommand({
meta: {
name: "launch-plan",
version: "1.0.0",
description: "Plan a launch from the terminal",
},
args: {
name: { type: "positional", required: true, description: "Launch name" },
plan: { type: "string", default: "free", description: "Plan" },
dryRun: { type: "boolean", description: "Print without creating" },
},
run({ args }) {
console.log(args.dryRun ? "Drafting " + args.name : "Creating " + args.name);
},
});
runMain(cittyCommand);The beginner trap is thinking the library removes validation. It removes parsing work. Your domain still decides which plans, names, folders, tokens, or ids are allowed.
Prompt answers need receipts too
An interactive prompt is a terminal question that waits for a user's answer. Prompt libraries can validate before accepting an answer. The rest of the program should read a trusted value, not a hopeful string.
Read-only reference code:
import { input, select } from "@inquirer/prompts";
type Plan = "free" | "pro";
type PromptAnswers = {
launchName: string;
plan: Plan;
};
function validateLaunchName(value: string): true | string {
return value.trim().length > 0 ? true : "Launch name is required";
}
export async function askLaunchQuestions(): Promise<PromptAnswers> {
const launchName = await input({
message: "Launch name",
validate: validateLaunchName,
});
const plan = await select<Plan>({
message: "Plan",
choices: [
{ name: "Free", value: "free" },
{ name: "Pro", value: "pro" },
],
});
return { launchName, plan };
}Prompts are user input, not a bypass around user input. If the answer controls files, money, deployments, or account access, validate it near the prompt.
Exit codes, signals, and binaries
An exit code is the number a process returns to the shell; 0 conventionally means success, and non-zero means failure. A signal is an operating-system message sent to a process, such as SIGINT from Control-C. A binary in npm package terms is an executable command exposed through package.json.
Read-only reference code:
import process from "node:process";
type ExitCode = 0 | 1 | 2;
type StopSignal = "SIGINT" | "SIGTERM";
function setExit(code: ExitCode): void {
process.exitCode = code;
}
function handleStop(signal: StopSignal): void {
process.stderr.write("Stopped by " + signal + "\\n");
process.exitCode = 1;
}
process.on("SIGINT", () => handleStop("SIGINT"));
process.on("SIGTERM", () => handleStop("SIGTERM"));
setExit(0);Set process.exitCode when you can, so normal pending output can finish. If you install signal listeners, make cleanup explicit; a listener changes the default path where Node exits for you.
To make the command npx-able, publish built JavaScript and point bin at the built executable. The referenced file should start with a shebang, the first line that tells the operating system to use Node.
{
"type": "module",
"bin": {
"launch-plan": "./dist/cli.js"
},
"files": ["dist"]
}# read-only after build and publish
npx launch-plan create "Search launch" --plan proThe bin field is the name tag for the executable file. npx can run package executables, but your artifact still needs the built file, shebang, and promised types.
Next, you design a library's public edge. Exported types are promises other people build on.
Checkpoint
Answer all three to mark this lesson complete