DEV Community

Wilson Xu
Wilson Xu

Posted on

TypeScript Tips for CLI Tool Development

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"]
}
Enter fullscreen mode Exit fullscreen mode

Key choices:

  • module: "NodeNext" — enables native ESM with .js extension imports
  • target: "ES2022" — gives you top-level await, structuredClone, and other modern features
  • declaration: true — generates .d.ts files 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);
  });
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

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';
// ...
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode

Or use a simpler approach with tsx as your runtime:

{
  "bin": { "mytool": "./bin/mytool.js" }
}
Enter fullscreen mode Exit fullscreen mode
// bin/mytool.js
#!/usr/bin/env node
import '../dist/cli.js';
Enter fullscreen mode Exit fullscreen mode

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';
  }
}
Enter fullscreen mode Exit fullscreen mode

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');
Enter fullscreen mode Exit fullscreen mode

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"
  }
}
Enter fullscreen mode Exit fullscreen mode
// 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+/);
  });
});
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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)