How to Develop CLI Using TypeScript

    Learn how to build powerful command-line interfaces (CLI) with TypeScript. This guide covers setup, argument parsing, error handling, and best practices for creating professional CLI tools.

    OpenCode

    OpenCode

    Developer

    12 min readTypeScript
    How to Develop CLI Using TypeScript

    TL;DR: Building CLIs with TypeScript combines the safety of static typing with the flexibility of Node.js. This guide covers project setup, argument parsing with popular libraries, creating interactive prompts, handling errors gracefully, and packaging your CLI for distribution.

    ---

    Why TypeScript for CLI Development?

    TypeScript offers several advantages when building command-line tools:

    • Type Safety: Catch errors at compile time before users encounter them
    • IntelliSense: Better IDE support and autocomplete for CLI APIs
    • Maintainability: Self-documenting code through type annotations
    • Performance: Compiled output runs fast with minimal overhead
    • Ecosystem: Access to the rich Node.js package ecosystem with TypeScript support

    Getting Started

    Project Setup

    First, initialize a new Node.js project:

    bash
    mkdir my-cli && cd my-cli
    npm init -y

    Install TypeScript and required dependencies:

    bash
    npm install --save-dev typescript ts-node @types/node
    npm install commander chalk

    Key packages:

    • typescript: The TypeScript compiler
    • ts-node: Run TypeScript files directly without compilation
    • commander: Popular CLI framework for argument parsing
    • chalk: Colorize terminal output

    Configure TypeScript

    Create a tsconfig.json:

    json
    {
      "compilerOptions": {
        "target": "ES2020",
        "module": "commonjs",
        "lib": ["ES2020"],
        "outDir": "./dist",
        "rootDir": "./src",
        "strict": true,
        "esModuleInterop": true,
        "skipLibCheck": true,
        "forceConsistentCasingInFileNames": true,
        "declaration": true,
        "declarationMap": true,
        "sourceMap": true,
        "resolveJsonModule": true
      },
      "include": ["src/**/*"],
      "exclude": ["node_modules", "dist"]
    }

    Project Structure

    Organize your CLI project logically:

    text
    my-cli/
    ├── src/
    │   ├── index.ts           # Entry point
    │   ├── commands/
    │   │   ├── init.ts
    │   │   └── build.ts
    │   ├── utils/
    │   │   ├── logger.ts
    │   │   └── validators.ts
    │   └── types.ts           # Shared types
    ├── dist/                  # Compiled output
    ├── tsconfig.json
    ├── package.json
    └── README.md

    ---

    Building Your First CLI

    Entry Point

    Create src/index.ts:

    typescript
    #!/usr/bin/env node
    
    import { program } from 'commander';
    import { version } from '../package.json';
    
    program
      .name('my-cli')
      .description('A powerful CLI tool')
      .version(version)
      .option('-v, --verbose', 'enable verbose output')
      .action((options) => {
        if (!process.argv.slice(2).length) {
          program.outputHelp();
        }
      });
    
    program.parse(process.argv);

    Adding Commands

    Create src/commands/init.ts:

    typescript
    import { Command } from 'commander';
    import chalk from 'chalk';
    import * as fs from 'fs';
    import * as path from 'path';
    
    export function addInitCommand(program: Command) {
      program
        .command('init [name]')
        .description('Initialize a new project')
        .option('-t, --template <template>', 'project template', 'basic')
        .action(async (name: string | undefined, options) => {
          try {
            const projectName = name || 'my-project';
            const projectPath = path.resolve(projectName);
    
            if (fs.existsSync(projectPath)) {
              console.error(chalk.red(`✗ Directory "${projectName}" already exists`));
              process.exit(1);
            }
    
            fs.mkdirSync(projectPath, { recursive: true });
            console.log(chalk.green(`✓ Created project "${projectName}"`));
    
            const packageJson = {
              name: projectName,
              version: '1.0.0',
              description: 'My awesome project',
              main: 'dist/index.js',
              scripts: {
                build: 'tsc',
                dev: 'ts-node src/index.ts'
              }
            };
    
            fs.writeFileSync(
              path.join(projectPath, 'package.json'),
              JSON.stringify(packageJson, null, 2)
            );
    
            console.log(chalk.blue(`ℹ Template: ${options.template}`));
          } catch (error) {
            console.error(chalk.red('✗ Initialization failed:'), error);
            process.exit(1);
          }
        });
    }

    Update src/index.ts to use the command:

    typescript
    #!/usr/bin/env node
    
    import { program } from 'commander';
    import { addInitCommand } from './commands/init';
    
    program
      .name('my-cli')
      .description('A powerful CLI tool')
      .version('1.0.0');
    
    addInitCommand(program);
    
    program.parse(process.argv);

    ---

    Advanced Features

    Interactive Prompts

    Add inquirer for interactive user input:

    bash
    npm install inquirer
    npm install --save-dev @types/inquirer
    typescript
    import inquirer from 'inquirer';
    
    async function promptUser() {
      const answers = await inquirer.prompt([
        {
          type: 'input',
          name: 'projectName',
          message: 'What is your project name?',
          default: 'my-project'
        },
        {
          type: 'checkbox',
          name: 'features',
          message: 'Select features:',
          choices: ['TypeScript', 'ESLint', 'Testing', 'Docker']
        },
        {
          type: 'confirm',
          name: 'install',
          message: 'Install dependencies?',
          default: true
        }
      ]);
    
      return answers;
    }

    Error Handling

    Create a utility for consistent error handling:

    typescript
    // src/utils/errorHandler.ts
    import chalk from 'chalk';
    
    export class CLIError extends Error {
      constructor(message: string, public code: number = 1) {
        super(message);
        this.name = 'CLIError';
      }
    }
    
    export function handleError(error: unknown) {
      if (error instanceof CLIError) {
        console.error(chalk.red(`✗ ${error.message}`));
        process.exit(error.code);
      } else if (error instanceof Error) {
        console.error(chalk.red(`✗ Error: ${error.message}`));
        process.exit(1);
      } else {
        console.error(chalk.red('✗ An unknown error occurred'));
        process.exit(1);
      }
    }

    Logging Utilities

    typescript
    // src/utils/logger.ts
    import chalk from 'chalk';
    
    export const logger = {
      success: (message: string) => console.log(chalk.green(`✓ ${message}`)),
      error: (message: string) => console.error(chalk.red(`✗ ${message}`)),
      warn: (message: string) => console.warn(chalk.yellow(`⚠ ${message}`)),
      info: (message: string) => console.log(chalk.blue(`ℹ ${message}`)),
      debug: (message: string) => console.log(chalk.gray(`◆ ${message}`))
    };

    ---

    Building and Packaging

    Build Configuration

    Add build scripts to package.json:

    json
    {
      "scripts": {
        "build": "tsc",
        "dev": "ts-node src/index.ts",
        "clean": "rm -rf dist"
      },
      "bin": {
        "my-cli": "./dist/index.js"
      }
    }

    Make Your CLI Executable

    After building, the shebang at the top of src/index.ts makes it executable:

    typescript
    #!/usr/bin/env node

    Then build and test:

    bash
    npm run build
    npm link  # Makes 'my-cli' available globally
    my-cli --help

    Publishing to npm

    1. Create an account on npm
    2. Update package.json with metadata:
    json
    {
      "name": "@yourname/my-cli",
      "version": "1.0.0",
      "description": "A powerful CLI tool",
      "keywords": ["cli", "typescript", "tool"],
      "author": "Your Name",
      "license": "MIT",
      "repository": {
        "type": "git",
        "url": "https://github.com/yourname/my-cli"
      }
    }
    1. Publish:
    bash
    npm login
    npm publish

    ---

    Best Practices

    1. **Type Everything**

    Use strict TypeScript settings and define interfaces for your CLI options and configurations.

    2. **Clear Help Text**

    Provide comprehensive help text with examples:

    typescript
    program
      .command('build')
      .description('Build the project')
      .example('my-cli build --production', 'Build for production')
      .option('-p, --production', 'production build')
      .action(handleBuild);

    3. **Exit Codes**

    Use meaningful exit codes (0 for success, 1+ for errors):

    typescript
    process.exit(0);  // Success
    process.exit(1);  // General error
    process.exit(2);  // Misuse of command

    4. **Validate Inputs**

    Always validate user inputs early:

    typescript
    export function validateProjectName(name: string): boolean {
      const pattern = /^[a-z0-9-]+$/;
      return pattern.test(name) && name.length > 0;
    }

    5. **Configuration Files**

    Support configuration files for common options:

    typescript
    import * as fs from 'fs';
    import * as path from 'path';
    
    export function loadConfig(configPath: string) {
      if (!fs.existsSync(configPath)) {
        return {};
      }
      return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
    }

    6. **Progress Indicators**

    For long-running operations, provide feedback:

    bash
    npm install cli-spinners ora
    typescript
    import ora from 'ora';
    
    const spinner = ora('Processing...').start();
    // Do work
    spinner.succeed('Complete!');

    ---

    Testing Your CLI

    Unit Tests with Jest

    bash
    npm install --save-dev jest @types/jest ts-jest

    Create a Jest config in package.json:

    json
    {
      "jest": {
        "preset": "ts-jest",
        "testEnvironment": "node",
        "testMatch": ["**/__tests__/**/*.test.ts"]
      }
    }

    Example test:

    typescript
    // src/__tests__/validators.test.ts
    import { validateProjectName } from '../utils/validators';
    
    describe('validators', () => {
      it('should validate valid project names', () => {
        expect(validateProjectName('my-project')).toBe(true);
        expect(validateProjectName('project123')).toBe(true);
      });
    
      it('should reject invalid project names', () => {
        expect(validateProjectName('My Project')).toBe(false);
        expect(validateProjectName('')).toBe(false);
      });
    });

    ---

    Common Pitfalls to Avoid

    • Not handling signals: Use process.on('SIGINT', ...) to cleanup gracefully
    • Synchronous file operations: Use async variants for better performance
    • Hardcoded paths: Use process.cwd() and relative paths
    • No version info: Always include version in help output
    • Unclear error messages: Be specific about what went wrong and how to fix it

    ---

    Conclusion

    Building CLIs with TypeScript combines safety, developer experience, and performance. Start simple with basic commands, gradually add features like interactivity and configuration files, and always prioritize clear error messages and helpful documentation.

    Your CLI is ready to be shared with the world!

    ---

    Resources

    Related Blog

    How to Develop CLI Using TypeScript | Tob