Skip to content
← back to thoughts

Building a CLI Tool in TypeScript

// 4 min read

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 --verbose
log.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, Field
from enum import Enum
import 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

  1. Keep dependencies minimal. Every npm install is a liability in a CLI tool. Users notice startup time.
  2. Write to stderr for logs, stdout for data. This makes piping work correctly: mycli build | jq .
  3. Fail fast with clear errors. Nobody wants a stack trace. Catch, format, exit with a non-zero code.
  4. 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.