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.
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:
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:
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:
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:
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.