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
Developer
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:
mkdir my-cli && cd my-cli
npm init -yInstall TypeScript and required dependencies:
npm install --save-dev typescript ts-node @types/node
npm install commander chalkKey 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:
{
"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:
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:
#!/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:
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:
#!/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:
npm install inquirer
npm install --save-dev @types/inquirerimport 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:
// 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
// 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:
{
"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:
#!/usr/bin/env nodeThen build and test:
npm run build
npm link # Makes 'my-cli' available globally
my-cli --helpPublishing to npm
- Create an account on npm
- Update
package.jsonwith metadata:
{
"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"
}
}- Publish:
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:
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):
process.exit(0); // Success
process.exit(1); // General error
process.exit(2); // Misuse of command4. **Validate Inputs**
Always validate user inputs early:
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:
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:
npm install cli-spinners oraimport ora from 'ora';
const spinner = ora('Processing...').start();
// Do work
spinner.succeed('Complete!');---
Testing Your CLI
Unit Tests with Jest
npm install --save-dev jest @types/jest ts-jestCreate a Jest config in package.json:
{
"jest": {
"preset": "ts-jest",
"testEnvironment": "node",
"testMatch": ["**/__tests__/**/*.test.ts"]
}
}Example test:
// 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!
---