How to Build a CLI Tool with TypeScript

We earn commissions when you shop through the links below.

Knowing how to build a CLI tool with TypeScript is one of those skills that pays dividends constantly. Automation scripts, internal dev tools, project scaffolders — once you can ship a proper CLI, you stop reaching for bash hacks and start building things that are actually maintainable. This guide walks through the full process: project setup, argument parsing, command structure, error handling, and packaging for distribution.

Why TypeScript for CLIs?

JavaScript is fine for throwaway scripts, but TypeScript gives you autocomplete, type-safe argument handling, and refactoring confidence that you’ll appreciate the moment your tool grows past 200 lines. The compilation step is minimal friction for a significant gain in reliability. If you’re writing anything that others will use or that you’ll revisit in six months, TypeScript is the right choice.

Project Setup

Start by initializing a Node.js project and installing your dependencies:

mkdir my-cli && cd my-cli
npm init -y
npm install --save-dev typescript @types/node tsx
npm install commander chalk ora

Here’s what each package does:

  • typescript + @types/node — TypeScript compiler and Node.js type definitions
  • tsx — runs TypeScript files directly without a separate compile step during development
  • commander — the industry-standard library for argument and subcommand parsing
  • chalk — terminal string styling
  • ora — elegant spinners for async operations

Now create your tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "commonjs",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "resolveJsonModule": true
  },
  "include": ["src"]
}

Update package.json to declare the binary entry point:

{
  "name": "my-cli",
  "version": "1.0.0",
  "bin": {
    "mycli": "./dist/index.js"
  },
  "scripts": {
    "dev": "tsx src/index.ts",
    "build": "tsc",
    "start": "node dist/index.js"
  }
}

Building the CLI Entry Point

Create src/index.ts. The shebang line at the top tells the OS to use Node.js when the file is executed directly:

#!/usr/bin/env node

import { Command } from 'commander';
import chalk from 'chalk';
import { greetCommand } from './commands/greet';
import { fetchCommand } from './commands/fetch';

const program = new Command();

program
  .name('mycli')
  .description('A demo CLI tool built with TypeScript')
  .version('1.0.0');

program.addCommand(greetCommand);
program.addCommand(fetchCommand);

program.parse(process.argv);

if (!process.argv.slice(2).length) {
  console.log(chalk.yellow('No command provided. Run --help for usage.'));
  program.outputHelp();
}

Creating Subcommands

Splitting commands into separate files keeps things organized. Create src/commands/greet.ts:

import { Command } from 'commander';
import chalk from 'chalk';

export const greetCommand = new Command('greet')
  .description('Greet a user by name')
  .argument('', 'The name to greet')
  .option('-l, --loud', 'Shout the greeting')
  .action((name: string, options: { loud?: boolean }) => {
    const message = `Hello, ${name}!`;
    if (options.loud) {
      console.log(chalk.bold.green(message.toUpperCase()));
    } else {
      console.log(chalk.green(message));
    }
  });

Now a more realistic example — a command that hits an API and shows a spinner. Create src/commands/fetch.ts:

import { Command } from 'commander';
import ora from 'ora';
import chalk from 'chalk';

interface Post {
  id: number;
  title: string;
  body: string;
}

export const fetchCommand = new Command('fetch')
  .description('Fetch a post from JSONPlaceholder by ID')
  .argument('', 'Post ID to fetch')
  .action(async (id: string) => {
    const spinner = ora('Fetching post...').start();

    try {
      const response = await fetch(`https://jsonplaceholder.typicode.com/posts/${id}`);

      if (!response.ok) {
        throw new Error(`HTTP ${response.status}: ${response.statusText}`);
      }

      const post: Post = await response.json();
      spinner.succeed('Post fetched successfully');

      console.log('');
      console.log(chalk.bold(`Title: ${post.title}`));
      console.log(chalk.dim(`ID: ${post.id}`));
      console.log(`\n${post.body}`);
    } catch (error) {
      spinner.fail('Failed to fetch post');
      if (error instanceof Error) {
        console.error(chalk.red(`Error: ${error.message}`));
      }
      process.exit(1);
    }
  });

Handling Configuration Files

Most real CLIs need a config file. A clean pattern is to look for a config in the current working directory or the user’s home directory. Create src/config.ts:

import fs from 'fs';
import path from 'path';
import os from 'os';

interface CliConfig {
  apiKey?: string;
  defaultFormat?: 'json' | 'table' | 'csv';
  verbose?: boolean;
}

export function loadConfig(): CliConfig {
  const localConfig = path.join(process.cwd(), '.myclirc.json');
  const globalConfig = path.join(os.homedir(), '.myclirc.json');

  const configPath = fs.existsSync(localConfig)
    ? localConfig
    : fs.existsSync(globalConfig)
    ? globalConfig
    : null;

  if (!configPath) return {};

  try {
    const raw = fs.readFileSync(configPath, 'utf-8');
    return JSON.parse(raw) as CliConfig;
  } catch {
    return {};
  }
}

Using Cursor while building this kind of boilerplate speeds things up considerably — its AI-assisted editing is genuinely useful for generating typed interfaces and repetitive command wiring.

Error Handling and Exit Codes

Exit codes matter. Tools that always exit with 0 are impossible to use in scripts. Establish a consistent pattern early:

// src/utils/exit.ts
import chalk from 'chalk';

export const EXIT_CODES = {
  SUCCESS: 0,
  GENERAL_ERROR: 1,
  MISUSE: 2,
  NOT_FOUND: 127,
} as const;

export function exitWithError(message: string, code: number = EXIT_CODES.GENERAL_ERROR): never {
  console.error(chalk.red(`✖ ${message}`));
  process.exit(code);
}

export function exitSuccess(message?: string): never {
  if (message) console.log(chalk.green(`✔ ${message}`));
  process.exit(EXIT_CODES.SUCCESS);
}

Building and Making it Executable

Compile your TypeScript:

npm run build

After compiling, you need to add the shebang to the output file (TypeScript strips it during compilation unless you use a plugin) and set execute permissions:

chmod +x dist/index.js

Then install it globally for local testing:

npm install -g .

Now you can run mycli greet World or mycli fetch 1 from anywhere on your machine.

Testing Your CLI

For unit testing individual command logic, separate the business logic from the Commander action handlers. Test the logic functions directly with Vitest or Jest — don’t try to test Commander’s parsing layer, that’s already tested by its maintainers.

// src/utils/format.ts
export function formatGreeting(name: string, loud: boolean): string {
  const message = `Hello, ${name}!`;
  return loud ? message.toUpperCase() : message;
}

// src/utils/format.test.ts
import { describe, it, expect } from 'vitest';
import { formatGreeting } from './format';

describe('formatGreeting', () => {
  it('returns a normal greeting', () => {
    expect(formatGreeting('Alice', false)).toBe('Hello, Alice!');
  });

  it('returns an uppercase greeting when loud is true', () => {
    expect(formatGreeting('Alice', true)).toBe('HELLO, ALICE!');
  });
});

Publishing to npm

If you want to distribute your tool, make sure your package.json includes the files field to avoid shipping source files:

{
  "files": ["dist"],
  "engines": {
    "node": ">=18.0.0"
  }
}

Then publish:

npm publish --access public

If you need to run the CLI as part of a larger deployment pipeline or want to host a web frontend for it, Railway is a straightforward place to deploy Node.js services without managing infrastructure yourself.

Wrapping Up

Understanding how to build a CLI tool with TypeScript opens up a whole category of automation and tooling work that’s genuinely fun to do. The combination of Commander for parsing, chalk for output, and ora for async feedback covers 90% of what you’ll need in practice. Keep commands small, separate your business logic from your CLI layer, and respect exit codes from day one.

If you want to go deeper on TypeScript in general, there are solid courses on Udemy that cover advanced patterns relevant to CLI and Node.js development. The foundation you build here — typed command structures, config loading, clean error handling — scales directly to production-grade internal tools.