Building Type-Safe CLI Tools with TypeScript
AI generated
type
TypeScript · CLI · Tooling
Building type-safe CLI tools
Commander and yargs with real type inference instead of string soup around flags and arguments

Internal CLI tools almost always grow organically: a flag here, a subcommand there, until nobody can say precisely what a user is allowed to type anymore. With Commander and yargs you can declare flags, options and subcommands so that TypeScript infers the handler signature automatically, turning typos in flag names into compile errors instead of silent undefined values at runtime.

10 min read Commander yargs Node.js CLI

1. Why untyped CLI arguments are a silent bug magnet

A classic Node CLI script reads process.argv, parses the strings by hand and passes loose objects into the business logic. Every flag name is a string literal, every option an optional field with no guarantee it actually exists. A typo like --outut instead of --output does not produce a compile error, it produces an undefined value that only turns into a cryptic runtime error deep inside the logic.

In a growing internal CLI with a dozen subcommands this problem multiplies: every handler gets its own hand-written interface for the options, which has to be manually kept in sync with every change to the flag definitions. This is exactly where Commander and yargs with TypeScript type inference help, the option definition becomes the single source of truth from which the handler parameter type follows automatically.

The effect is especially noticeable in tools maintained by several developers: whoever renames a flag immediately sees red underlines in the editor for every handler that needs adjusting, instead of finding out at the next CI run or, worse, the next production invocation.


// Without inference: a manual interface that drifts over time
interface DeployOptionsManual {
  environment?: string;
  dryRun?: boolean;
  // forgotten: --force was added three weeks ago
}

function runManual(opts: DeployOptionsManual) {
  // opts.force exists at runtime but is invisible in the type
}

2. Commander: declaring the program, options and subcommands

Commander builds up a Command object by chaining .option() and .argument(). Since Commander 9 added TypeScript definitions, .opts<T>() derives its return type from an explicit generic parameter, which is considerably more robust for larger CLIs than relying on automatic inference alone.

For smaller tools the built-in inference from the option chain is often sufficient, but for CLIs with many optional and required flags it pays off to define the option interface explicitly and bind it to opts() through a generic. That keeps the handler code fully type-safe without depending on the library's inference heuristics.


import { Command } from "commander";

interface DeployOptions {
  environment: "staging" | "production";
  dryRun: boolean;
  force?: boolean;
}

const program = new Command();

program
  .name("deploy-cli")
  .requiredOption("-e, --environment <env>", "target environment")
  .option("--dry-run", "simulate only, do not execute", false)
  .option("--force", "skip the confirmation prompt")
  .action((_opts, cmd) => {
    const opts = cmd.optsWithGlobals<DeployOptions>();
    // opts.environment is known type-safely as "staging" | "production"
    console.log(`Deploying to ${opts.environment}, dryRun=${opts.dryRun}`);
  });

program.parse();

3. yargs: the builder pattern with inferred option types

yargs takes a different approach than Commander: chained .option() calls let yargs infer the type of the resulting argv object automatically from the builder object, with no manual interface required. The type passed per option (string, boolean, number, array) flows directly into the inferred type.

Particularly useful is choices: when an option is restricted to a fixed list of values, yargs automatically infers a union of string literals instead of a generic string, which surfaces typos in allowed values right in the editor.


import yargs from "yargs";
import { hideBin } from "yargs/helpers";

const argv = yargs(hideBin(process.argv))
  .option("environment", {
    alias: "e",
    type: "string",
    choices: ["staging", "production"] as const,
    demandOption: true,
  })
  .option("dryRun", { type: "boolean", default: false })
  .option("retries", { type: "number", default: 3 })
  .parseSync();

// argv.environment is "staging" | "production", not a plain string
console.log(argv.environment, argv.dryRun, argv.retries);

4. Subcommands with their own typed option scope

Both Commander and yargs support subcommands, where each command gets its own set of options. The key to type safety is letting every subcommand module export its own option interface and binding it consistently to the handler, rather than sharing one global options object across all subcommands.

In yargs this is achieved with the .command() overload that takes a builder callback, whose return type automatically types the handler parameter. In Commander, each subcommand is modeled as its own Command object with its own option interface, which makes the separation even more explicit.


import yargs from "yargs";

interface MigrateArgs {
  target: string;
  steps: number;
}

yargs(process.argv.slice(2))
  .command<MigrateArgs>(
    "migrate <target>",
    "run a database migration",
    (y) =>
      y
        .positional("target", { type: "string", demandOption: true })
        .option("steps", { type: "number", default: 1 }),
    (args) => {
      // args is fully typed as MigrateArgs
      runMigration(args.target, args.steps);
    }
  )
  .demandCommand(1)
  .parse();

function runMigration(target: string, steps: number) {
  console.log(`Migrating ${target} by ${steps} steps`);
}

5. Adding runtime validation with zod

Type inference at the Commander or yargs level only secures the structure the library itself knows about, it does not protect against semantically invalid input such as a negative port number or a file that does not exist. For these cases an additional validation layer with zod is worth adding, whose schema doubles as the type source for the rest of the code.

The advantage of this combination: the CLI library handles parsing and help text, zod handles semantic validation with meaningful error messages, and both stay in sync because the zod schema type feeds into the handler through z.infer.


import { z } from "zod";

const DeploySchema = z.object({
  environment: z.enum(["staging", "production"]),
  port: z.number().int().positive().max(65535),
});

type DeployArgs = z.infer<typeof DeploySchema>;

function validateAndRun(raw: unknown): void {
  const result = DeploySchema.safeParse(raw);
  if (!result.success) {
    console.error(result.error.issues.map((i) => i.message).join("\n"));
    process.exit(1);
  }
  execute(result.data);
}

function execute(args: DeployArgs) {
  console.log(`Starting on port ${args.port} in ${args.environment}`);
}

6. Modeling exit codes and error classes with types

One often neglected part of CLI tools is the exit code: scripts running inside CI pipelines need to reliably distinguish success, an expected failure and an unexpected crash. A simple enum for exit codes plus a base error class with an associated code makes this distinction type-safe and prevents a magic number like process.exit(2) from showing up somewhere in the code without context.

Combined with a central try/catch wrapping the entire CLI entry point, this guarantees every error path produces a clearly defined, documented exit code, instead of Node implicitly exiting with code 1 on an unhandled exception.


enum ExitCode {
  Success = 0,
  ValidationError = 1,
  NetworkError = 2,
  Unexpected = 70,
}

class CliError extends Error {
  constructor(message: string, public readonly code: ExitCode) {
    super(message);
  }
}

async function main() {
  try {
    await runCommand();
    process.exit(ExitCode.Success);
  } catch (err) {
    if (err instanceof CliError) {
      console.error(err.message);
      process.exit(err.code);
    }
    console.error("Unexpected error:", err);
    process.exit(ExitCode.Unexpected);
  }
}

async function runCommand(): Promise<void> {
  throw new CliError("Invalid environment", ExitCode.ValidationError);
}

7. Testing CLI handlers in isolation

Because handler functions accept typed arguments instead of raw process.argv strings, they can be called directly in unit tests with typed objects, without testing the parser itself at the same time. This cleanly separates parsing logic, which you test once against the library, from business logic, which you cover with many scenarios.

In practice this means a test calls execute({ environment: "staging", port: 3000 }) directly, without spawning a child process or mocking process.argv, which makes the test suite noticeably faster and more robust.

8. Packaging and the bin entry point

A distributable CLI needs a bin field in package.json pointing at the compiled JavaScript file, and that file needs the shebang line #!/usr/bin/env node at the very top. It matters that the TypeScript compiler does not strip this line during the build, many build setups add it back with a postbuild script instead.

If you publish the CLI publicly on npm, it is worth also exporting the type definitions of the option interfaces in case other packages want to call the CLI programmatically instead of through the command line, saving consumers a duplicate type definition.


{
  "name": "deploy-cli",
  "bin": {
    "deploy-cli": "./dist/cli.js"
  },
  "scripts": {
    "build": "tsc && chmod +x dist/cli.js"
  }
}

9. When the typing effort pays off

For a one-off script with two flags, a full type-inference pipeline with zod validation and an exit code enum is overkill, a few destructured argv values are enough. But as soon as a CLI tool is maintained by several developers, has multiple subcommands, or is wired into CI pipelines, every hour invested in type safety pays itself back several times over.

The pragmatic middle ground many teams settle on: Commander or yargs for parsing and help text, a lean zod schema for semantic validation at the critical points, and a central error and exit code concept designed in from the start rather than bolted on later.

Feature Commander yargs Plain argv parsing
Type inference from options opts<T>() with a generic automatic from the builder none, manual
Subcommands separate Command objects .command() with a builder manually branched
Choices as a union type not native yes, via as const not available
Help text generation automatic automatic manual
Bundle size small medium minimal

Mironsoft

TypeScript migration, type safety, and team onboarding

A JavaScript codebase without type safety, but no time for a full migration?

We migrate existing JavaScript projects to TypeScript step by step, set up strict compiler settings cleanly, and bring teams to the same type-safety level with code reviews and style guides.

Migration Roadmap

Plan and execute a gradual JS-to-TS migration without big-bang risk.

Strict Mode Rollout

Set up tsconfig.json, ESLint rules, and CI checks for lasting type safety.

Team Onboarding

Bring developers up to speed on TypeScript best practices with workshops and reviews.

10. Summary

CLI tools

Libraries

Commander and yargs with generic type inference for options

Validation

zod schema as an additional runtime check and type source

Error paths

Exit code enum plus a central CliError class

Testability

Handlers take typed objects, no argv mocking required

11. FAQ: CLI tools

1Is Commander or yargs better suited for TypeScript?
Both offer usable type inference, yargs derives types more automatically from the builder, while Commander often gives more precise results with an explicit generic on opts(). For small tools yargs gets you started faster, for complex subcommand trees Commander gives a clearer structure.
2Do I still need zod if I already have yargs types?
Yes, whenever semantic rules go beyond pure type structure, such as value ranges or file paths that must exist. yargs and Commander only check whether a flag has the right JavaScript type, not whether the value makes sense content-wise.
3How do I handle optional flags that have a default?
Set the default directly in the library's option definition, then TypeScript automatically infers a non-optional type for that field, because a value is guaranteed to be present at runtime.
4Is a CLI library worth it for a script with a single flag?
Rarely, a simple destructured process.argv is usually enough. The benefit of Commander or yargs only shows once you have multiple options, subcommands, or need automatically generated help text.
5How do I test that the CLI produces the right exit codes?
Most reliably with an integration test that spawns the compiled CLI as a child process and checks the actual process.exitCode, complemented by unit tests of the handler functions for the business logic itself.
6Can I combine subcommand options with global options?
Yes, both libraries support global options available across all subcommands. In Commander via optsWithGlobals(), in yargs by defining global options before the first command() call.
7What happens if a user passes an unknown flag?
Both libraries fail by default with an error message as long as strict mode is enabled. In yargs this is enforced via .strict(), in Commander it is the default behavior for undeclared options.
8Do I have to compile the CLI to JavaScript, or can I run it directly?
For production it is usually compiled to dist/, while during development the CLI often runs directly via tsx or ts-node to skip the build step on every iteration.
9How do I handle version output in the CLI?
Both libraries offer a .version() method that typically reads the value from package.json. It matters to freeze this value at build time rather than reading it dynamically from the filesystem if the CLI gets installed globally.
10Should every internal CLI be published publicly on npm?
No, for purely internal tools a private package or a script exposed through a workspace bin field is enough. Public publishing only pays off once external teams actually need to use the tool.