Building a CLI Tool in TypeScript
I’ve been building CLI tools for years. Most of them were throwaway scripts — until I needed something production-grade. Here’s how I approached it.
The setup
Starting with a clean TypeScript project using tsup for bundling. No Webpack, no Rollup config hell. Just a tsup.config.ts and you’re done.
import { defineConfig } from "tsup";
export default defineConfig({ entry: ["src/cli.ts"], format: ["esm"], target: "node20", clean: true, dts: true, shims: true,});Parsing arguments
I used to reach for yargs or commander. These days, I parse args manually and validate with Zod. Fewer dependencies, full control.
import { z } from "zod";
const argsSchema = z.object({ command: z.enum(["init", "build", "deploy"]), flags: z.object({ verbose: z.boolean().default(false), output: z.string().optional(), concurrency: z.number().int().positive().default(4), }),});
function parseArgs(argv: string[]) { const [command, ...rest] = argv.slice(2); const flags: Record<string, unknown> = {};
for (let i = 0; i < rest.length; i++) { if (rest[i] === "--verbose") flags.verbose = true; if (rest[i] === "--output") flags.output = rest[++i]; if (rest[i] === "--concurrency") flags.concurrency = Number(rest[++i]); }
return argsSchema.parse({ command, flags });}The nice thing about Zod here is you get validation and types in one pass. If someone passes --concurrency abc, it throws a clear error.
Structured logging
console.log doesn’t cut it for CLI tools. You need levels, colors, and structured output for piping.
import { createLogger } from "./logger";
const log = createLogger({ verbose: flags.verbose });
log.info("Starting build", { output: flags.output });log.debug("Resolved config", { config }); // only shown with --verboselog.error("Build failed", { error: err.message });The implementation is ~40 lines. No need for winston or pino in a CLI context.
10 collapsed lines
type Level = "debug" | "info" | "warn" | "error";
const COLORS: Record<Level, string> = { debug: "\x1b[90m", info: "\x1b[36m", warn: "\x1b[33m", error: "\x1b[31m",};
const RESET = "\x1b[0m";
export function createLogger(opts: { verbose: boolean }) { return { debug(msg: string, data?: Record<string, unknown>) { if (!opts.verbose) return; write("debug", msg, data); }, info(msg: string, data?: Record<string, unknown>) { write("info", msg, data); }, warn(msg: string, data?: Record<string, unknown>) { write("warn", msg, data); }, error(msg: string, data?: Record<string, unknown>) { write("error", msg, data); }, };}
function write(level: Level, msg: string, data?: Record<string, unknown>) { const prefix = `${COLORS[level]}[${level}]${RESET}`; const suffix = data ? ` ${JSON.stringify(data)}` : ""; process.stderr.write(`${prefix} ${msg}${suffix}\n`);}Handling async operations
Most CLI tools need to do async work — reading files, making HTTP requests, spawning processes. Here’s a pattern I use for concurrent file processing:
async function processFiles(paths: string[], concurrency: number) { const results: Map<string, Result> = new Map(); const queue = [...paths];
async function worker() { while (queue.length > 0) { const path = queue.shift()!; try { const content = await Bun.file(path).text(); const transformed = await transform(content); results.set(path, { ok: true, data: transformed }); } catch (err) { results.set(path, { ok: false, error: String(err) }); } } }
await Promise.all(Array.from({ length: concurrency }, () => worker())); return results;}Python equivalent
For comparison, here’s how I’d do the same arg parsing in Python with Pydantic:
16 collapsed lines
from pydantic import BaseModel, Fieldfrom enum import Enumimport sys
class Command(str, Enum): init = "init" build = "build" deploy = "deploy"
class Flags(BaseModel): verbose: bool = False output: str | None = None concurrency: int = Field(default=4, gt=0)
class Args(BaseModel): command: Command flags: Flags
def parse_args(argv: list[str]) -> Args: args = argv[1:] command = args[0] if args else "" flags: dict = {}
i = 1 while i < len(args): match args[i]: case "--verbose": flags["verbose"] = True case "--output": i += 1 flags["output"] = args[i] case "--concurrency": i += 1 flags["concurrency"] = int(args[i]) i += 1
return Args(command=command, flags=Flags(**flags))
if __name__ == "__main__": config = parse_args(sys.argv) print(f"Running {config.command.value} with {config.flags}")Same idea — schema validates and types in one shot. Pydantic and Zod are spiritual siblings.
Lessons learned
- Keep dependencies minimal. Every
npm installis a liability in a CLI tool. Users notice startup time. - Write to stderr for logs, stdout for data. This makes piping work correctly:
mycli build | jq . - Fail fast with clear errors. Nobody wants a stack trace. Catch, format, exit with a non-zero code.
- Test the arg parser separately. It’s the most fiddly part and the easiest to unit test.
The full source is about 300 lines. No framework, no magic. Just TypeScript doing what it does best.