TypeScript Tips for CLI Tool Development
TypeScript makes CLI tools more reliable, but getting the setup right requires knowing a few non-obvious patterns. This article covers the TypeScript-specific decisions that matter most when building CLI tools — from project configuration to type-safe argument parsing to publishing compiled output.
1. The Right tsconfig.json for CLI Tools
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"outDir": "dist",
"rootDir": "src",
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src"],
"exclude": ["node_modules", "dist", "test"]
}
Key choices:
-
module: "NodeNext"— enables native ESM with.jsextension imports -
target: "ES2022"— gives you top-levelawait,structuredClone, and other modern features -
declaration: true— generates.d.tsfiles so your CLI can also be used as a library -
sourceMap: true— makes error stack traces point to TypeScript source, not compiled output
2. Type-Safe Argument Parsing with Commander
Commander supports TypeScript well, but you need to extract the types manually:
// src/cli.ts
import { program, Option } from 'commander';
interface AuditOptions {
threshold?: number;
format: 'text' | 'json' | 'table';
mobile: boolean;
verbose: boolean;
timeout: number;
}
program
.command('audit <url>')
.addOption(
new Option('-f, --format <fmt>', 'Output format')
.choices(['text', 'json', 'table'])
.default('text')
)
.option('-t, --threshold <n>', 'Minimum score', parseInt)
.option('-m, --mobile', 'Mobile emulation', false)
.option('-v, --verbose', 'Verbose output', false)
.option('--timeout <ms>', 'Request timeout', parseInt, 30000)
.action(async (url: string, options: AuditOptions) => {
// options.format is typed as 'text' | 'json' | 'table'
// options.threshold is typed as number | undefined
await runAudit(url, options);
});
3. Typed Configuration with Zod
For config file validation, Zod gives you runtime validation AND TypeScript types from a single source:
// src/config.ts
import { z } from 'zod';
const ConfigSchema = z.object({
threshold: z.number().min(0).max(100).default(80),
maxErrors: z.number().min(0).default(0),
format: z.enum(['text', 'json', 'table']).default('text'),
targets: z.array(z.string().url()).optional(),
verbose: z.boolean().default(false),
timeout: z.number().min(1000).max(300000).default(30000),
});
// Infer the TypeScript type from the schema
type Config = z.infer<typeof ConfigSchema>;
export function parseConfig(raw: unknown): Config {
const result = ConfigSchema.safeParse(raw);
if (!result.success) {
const errors = result.error.issues
.map(i => ` ${i.path.join('.')}: ${i.message}`)
.join('\n');
throw new Error(`Invalid configuration:\n${errors}`);
}
return result.data;
}
Now your config is validated at runtime AND typed at compile time — from one definition.
4. The bin + dist Pattern
Your package.json points bin to compiled output, but you develop in TypeScript:
{
"name": "mytool",
"bin": { "mytool": "./dist/cli.js" },
"files": ["dist"],
"scripts": {
"build": "tsc",
"dev": "tsx src/cli.ts",
"prepublishOnly": "npm run build"
}
}
The key insight: bin points to dist/cli.js (compiled), but during development you run tsx src/cli.ts (source). The prepublishOnly script ensures code is compiled before publishing.
Your compiled entry point needs the shebang:
// src/cli.ts
#!/usr/bin/env node
import { program } from 'commander';
// ...
TypeScript strips the shebang during compilation, so add it back in your build script:
{
"scripts": {
"build": "tsc && echo '#!/usr/bin/env node' | cat - dist/cli.js > dist/cli.tmp && mv dist/cli.tmp dist/cli.js && chmod +x dist/cli.js"
}
}
Or use a simpler approach with tsx as your runtime:
{
"bin": { "mytool": "./bin/mytool.js" }
}
// bin/mytool.js
#!/usr/bin/env node
import '../dist/cli.js';
5. Error Types for CLI
Create a typed error hierarchy:
// src/errors.ts
export class CliError extends Error {
constructor(
message: string,
public readonly code: number = 1,
public readonly suggestion?: string,
public readonly details?: string,
) {
super(message);
this.name = 'CliError';
}
}
export class NetworkError extends CliError {
constructor(url: string, status: number, message?: string) {
super(
`Request to ${url} failed with status ${status}`,
3,
status === 429
? 'Rate limited — retry in a few minutes'
: status >= 500
? 'Server error — try again later'
: undefined,
message,
);
this.name = 'NetworkError';
}
}
export class ValidationError extends CliError {
constructor(
public readonly field: string,
message: string,
) {
super(`Validation error in "${field}": ${message}`, 2);
this.name = 'ValidationError';
}
}
6. Type-Safe Process Exit
Wrap process.exit to ensure you always use valid exit codes:
const EXIT_CODES = {
SUCCESS: 0,
GENERAL_ERROR: 1,
INVALID_INPUT: 2,
NETWORK_ERROR: 3,
TIMEOUT: 4,
} as const;
type ExitCode = typeof EXIT_CODES[keyof typeof EXIT_CODES];
function exit(code: ExitCode, message?: string): never {
if (message) {
const stream = code === 0 ? process.stdout : process.stderr;
stream.write(message + '\n');
}
process.exit(code);
}
// Usage
exit(EXIT_CODES.INVALID_INPUT, 'Missing required argument: url');
7. Testing TypeScript CLIs
Use tsx to run tests against source directly, avoiding the compile step:
{
"scripts": {
"test": "vitest",
"test:compiled": "npm run build && vitest run --config vitest.compiled.config.ts"
}
}
// test/cli.test.ts
import { describe, it, expect } from 'vitest';
import { execaNode } from 'execa';
describe('CLI', () => {
it('runs from compiled output', async () => {
const result = await execaNode('dist/cli.js', ['--version']);
expect(result.stdout).toMatch(/\d+\.\d+\.\d+/);
});
it('runs from source with tsx', async () => {
const result = await execa('npx', ['tsx', 'src/cli.ts', '--version']);
expect(result.stdout).toMatch(/\d+\.\d+\.\d+/);
});
});
8. Generics for Reusable CLI Patterns
Build generic patterns that work across commands:
type OutputFormatter<T> = {
text: (data: T) => string;
json: (data: T) => string;
table: (data: T) => string;
};
function formatOutput<T>(
data: T,
format: keyof OutputFormatter<T>,
formatters: OutputFormatter<T>,
): string {
return formatters[format](data);
}
// Usage with specific types
interface AuditResult {
score: number;
metrics: Record<string, number>;
}
const auditFormatters: OutputFormatter<AuditResult> = {
text: (r) => `Score: ${r.score}/100`,
json: (r) => JSON.stringify(r, null, 2),
table: (r) => formatTable(r), // your table formatter
};
console.log(formatOutput(result, options.format, auditFormatters));
Conclusion
TypeScript adds real value to CLI tools: type-safe arguments, validated configs, typed errors, and compile-time guarantees. The setup cost is small — a tsconfig.json, a prepublishOnly script, and a few type definitions — but the payoff grows with every feature you add.
Wilson Xu builds TypeScript CLI tools and publishes them on npm. Follow at dev.to/chengyixu.
Top comments (0)