Back to posts

Building CLI Tools: A Multi-Language Journey

Exploring how to build command-line tools in TypeScript, Python, Rust, and Go—each with its own strengths and trade-offs.

clitypescriptpythonrustgo

Command-line interfaces remain one of the most powerful ways to interact with software. In this post, we'll explore building a simple CLI tool across four different languages.

The Problem

Let's build a tool that reads a JSON file and outputs formatted statistics. Simple enough to implement quickly, but complex enough to showcase each language's idioms.

TypeScript Implementation

TypeScript gives us excellent developer experience with type safety. Here's a clean implementation using Node.js:

typescript
import { readFileSync } from 'fs';
import { join } from 'path';

interface Stats {
  count: number;
  keys: string[];
  types: Record<string, number>;
}

function analyzeJson(filePath: string): Stats {
  const content = readFileSync(filePath, 'utf-8');
  const data = JSON.parse(content);
  
  const types: Record<string, number> = {};
  const keys = Object.keys(data);
  
  for (const value of Object.values(data)) {
    const type = typeof value;
    types[type] = (types[type] || 0) + 1;
  }
  
  return { count: keys.length, keys, types };
}

const stats = analyzeJson(process.argv[2]);
console.log(JSON.stringify(stats, null, 2));

The beauty here is how types make the code self-documenting. Anyone reading this immediately understands the shape of our data.

Python Version

Python's simplicity shines for quick scripts:

python
import json
import sys
from collections import Counter

def analyze_json(file_path: str) -> dict:
    with open(file_path, 'r') as f:
        data = json.load(f)
    
    types = Counter(type(v).__name__ for v in data.values())
    
    return {
        'count': len(data),
        'keys': list(data.keys()),
        'types': dict(types)
    }

if __name__ == '__main__':
    stats = analyze_json(sys.argv[1])
    print(json.dumps(stats, indent=2))

Python's Counter from collections makes frequency counting elegant. The code reads almost like pseudocode.

Rust: When Performance Matters

Rust requires more ceremony but gives us blazing performance and memory safety:

rust
use serde_json::Value;
use std::collections::HashMap;
use std::fs;

fn analyze_json(path: &str) -> Result<Stats, Box<dyn std::error::Error>> {
    let content = fs::read_to_string(path)?;
    let data: HashMap<String, Value> = serde_json::from_str(&content)?;
    
    let mut types: HashMap<String, usize> = HashMap::new();
    
    for value in data.values() {
        let type_name = match value {
            Value::Null => "null",
            Value::Bool(_) => "boolean",
            Value::Number(_) => "number",
            Value::String(_) => "string",
            Value::Array(_) => "array",
            Value::Object(_) => "object",
        };
        *types.entry(type_name.to_string()).or_insert(0) += 1;
    }
    
    Ok(Stats {
        count: data.len(),
        keys: data.keys().cloned().collect(),
        types,
    })
}

The explicit error handling and pattern matching make edge cases impossible to ignore.

Go: Simple and Concurrent

Go strikes a balance between simplicity and performance:

go
package main

import (
    "encoding/json"
    "fmt"
    "os"
)

type Stats struct {
    Count int               `json:"count"`
    Keys  []string          `json:"keys"`
    Types map[string]int    `json:"types"`
}

func analyzeJSON(path string) (*Stats, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, err
    }
    
    var parsed map[string]interface{}
    if err := json.Unmarshal(data, &parsed); err != nil {
        return nil, err
    }
    
    types := make(map[string]int)
    keys := make([]string, 0, len(parsed))
    
    for key, value := range parsed {
        keys = append(keys, key)
        types[fmt.Sprintf("%T", value)]++
    }
    
    return &Stats{Count: len(parsed), Keys: keys, Types: types}, nil
}

Go's straightforward approach and excellent standard library make it perfect for infrastructure tools.

Conclusion

Each language brings something unique:

  • TypeScript: Best developer experience, great for teams
  • Python: Fastest to prototype, excellent libraries
  • Rust: Maximum performance, memory safety
  • Go: Simple deployment, great concurrency

Choose based on your constraints—there's no universally "best" option.

The best tool is the one that gets the job done while keeping your team productive.