Writing CLI Tools in 2023: Rust, Go, or TypeScript?

If you’ve ever shipped a CLI and then watched it break because someone’s environment differed from yours, you already know the real problem isn’t the command—it’s the tooling choices that sit underneath it. In 2023, you don’t have to settle for brittle scripts anymore. You can build modern, ergonomic CLI tools that feel like first-class software—only now you get to choose the language that best fits the job.
The “right” answer is less about what’s trendy and more about what your users will do with your tool: how fast it needs to be, how many dependencies it should carry, and whether your team lives in Node or prefers a compile-and-ship workflow.
The modern CLI reality: you’re shipping behavior, not just code⌗
A CLI tool is a contract. It promises:
- Predictable flags and help text
- Stable parsing across shells and environments
- Good error messages (with non-zero exit codes)
- A deployment story that won’t collapse on someone else’s machine
In earlier years, “good enough” often meant Python with argparse, or bash scripts that stitched together other programs. Those approaches can still work—but they tend to fail in the places teams care about most: dependency drift, version mismatches, packaging pain, and inconsistent argument parsing.
So let’s talk about three languages that make those problems dramatically easier in 2023: Go (with Cobra), Rust (with Clap), and TypeScript (with Commander).
Go: pick it when you want single-binary simplicity and fast iteration⌗
Go’s superpower for CLI tools is straightforward: you can usually build a static-ish, single-binary executable quickly, with a deployment story that’s easy to explain. That matters when your users just want a command—no runtime setup, no “please install Node 18” footnotes, no container requirements.
Why Cobra fits Go’s strengths⌗
Cobra is a mature library with a familiar structure for defining commands and subcommands. It encourages you to model your CLI like a small app, not a pile of string parsing. Flags, help output, and command composition become “boring” in the best way.
Practical example: a workspace cleanup utility⌗
Imagine a CLI like:
tool purge --dry-runtool purge --days 30tool purge --path ./disttool config set region us-east-1
With Cobra, you can define subcommands cleanly and keep option wiring readable:
- One command file per major subcommand
- Central config handling
- Consistent exit codes and messaging
The real win is that Go lets you ship a utility that’s easy to install and fast to run—exactly what you want for operational scripts and developer productivity tools.
When Go is the wrong choice⌗
Go isn’t ideal when your CLI is fundamentally a data-processing engine where you need tight control over memory layout or you’re squeezing every millisecond out of streaming. Go can handle that work, but if performance and predictable resource usage at scale are the whole point, Rust usually becomes the sharper tool.
Rust: pick it when correctness, performance, and large data are the product⌗
Rust is what you reach for when “CLI tool” is really shorthand for “high-throughput system.” Think: parsing logs at scale, transforming large datasets, indexing files, running ETL-like pipelines, or any workload where speed and resource efficiency are non-negotiable.
Why Clap fits Rust’s ergonomics⌗
Clap is the de facto standard for CLI argument parsing in Rust. It gives you powerful declarative configuration for options, subcommands, and validation. You also get help output that feels polished without you spending hours polishing it.
The bigger point: Rust makes it easier to write a CLI whose failure modes are clean. When things go wrong, you can surface precise errors instead of dumping stack traces or swallowing edge cases.
Practical example: streaming log processing⌗
Consider a tool like:
logx grep --pattern "ERROR" --input ./logs --output ./report.jsonllogx stats --group-by service --input ./logs
If you need to stream input efficiently, avoid loading everything into memory, and process files concurrently, Rust shines. You can build a pipeline where:
- Input is read incrementally
- Parsing is robust (with clear errors for malformed lines)
- Output is written in a controlled format
- Concurrency is explicit and safe
This is also where Rust tends to pay off in long-lived tools: memory safety and strong typing reduce “it worked on my machine” bugs that only surface under real workloads.
When Rust is the wrong choice⌗
Rust can be overkill when the CLI is mainly glue—wrapping a few subprocess calls, moving files around, or orchestrating existing tools. If your tool’s primary value is workflow convenience and you don’t need heavy lifting, Go will usually get you to a shippable result faster.
TypeScript: pick it when your users are already in the Node ecosystem⌗
TypeScript makes sense when your CLI is part of a larger ecosystem that already runs on Node: dev tools, integrations, build tooling, plugin systems, and anything that benefits from npm’s distribution model.
Why Commander fits TypeScript well⌗
Commander is a popular choice for defining commands and options, with a pragmatic programming model that works well for JS/TS teams. You’ll find it especially natural to build a CLI that talks to:
- npm packages
- REST APIs
- local project configuration files (like
package.json) - JavaScript-based tooling
TypeScript also lets you share types between the CLI and the underlying libraries. That can be a big quality-of-life improvement: you don’t just parse flags—you generate strongly typed config objects that downstream code understands.
Practical example: a project scaffolding CLI⌗
Suppose you’re building:
mygen init --template react --name acme-appmygen install --with eslint --with prettiermygen doctorto validate the project setup
In a Node-centric workflow, TypeScript shines because the CLI and the libraries live in the same universe. Installation might even be as simple as npx mygen or npm i -g mygen depending on your distribution goals.
When TypeScript is the wrong choice⌗
TypeScript is less compelling when you want the simplest “drop a single binary on a server” experience or when your CLI must be standalone with minimal runtime concerns. If users expect curl | bash-style installation and zero runtime assumptions, Rust or Go tends to be the smoother experience.
Also, if your tool is heavy on raw data processing, TypeScript will likely feel frictional compared to Rust or even Go.
What about Python? Don’t resurrect the original pain⌗
Python still exists—and some teams genuinely like it. But for a CLI that must be reliable across machines and time, Python scripts are where many projects go when they didn’t plan for operational longevity.
The failure pattern is predictable:
- Someone has Python 3.10, someone else has 3.11 (or none)
- Dependencies behave differently across minor versions
- A packaging mistake turns “works locally” into “it fails on CI”
- Argument parsing logic becomes inconsistent and hard to test
If Python is your starting point, you’ll want disciplined packaging and pinned dependencies. But if your goal is to ship a CLI that behaves like infrastructure—not a quick personal script—Rust, Go, or TypeScript are usually the more dependable choices.
A decision framework you can actually use⌗
Here’s a blunt but useful way to choose:
Choose Go + Cobra if…⌗
- You want a single-binary developer utility
- Your CLI is primarily orchestration or lightweight processing
- You value fast compilation, straightforward deployment, and quick iteration
- Your team prefers minimal runtime dependencies
Rule of thumb: if your users just need tool do-thing to work everywhere, Go is your best bet.
Choose Rust + Clap if…⌗
- The tool must process large datasets efficiently
- Performance and predictable resource usage matter
- You care deeply about correctness, validation, and clean failure modes
- You’re building something closer to a real system than a wrapper
Rule of thumb: if the CLI is the engine, Rust is usually the engine room.
Choose TypeScript + Commander if…⌗
- Your users are already Node/TypeScript developers
- The CLI integrates with npm packages, project configs, or web APIs
- You want to share types and reuse libraries across CLI + runtime
- Distribution via npm is part of your product strategy
Rule of thumb: if the CLI is part of a JavaScript workflow, TypeScript will feel native.
Make your CLI feel “professional” regardless of language⌗
Language matters, but you can still raise the baseline quality of your CLI quickly:
- Treat help output as product. Add examples to
--helpand ensure flags are consistent across subcommands. - Validate early. Don’t wait until halfway through processing to discover a bad flag.
- Be explicit in errors. Print actionable messages and exit with non-zero codes.
- Support
--dry-runwhen the CLI modifies things. It builds trust. - Keep output stable. If you offer machine-readable formats (JSON, NDJSON), document them and don’t casually break them.
Also: design your commands before your implementation. If your CLI architecture is clear, your library choice becomes an implementation detail rather than a regret.
Conclusion: the best language is the one that matches your users⌗
Go, Rust, and TypeScript are all excellent in 2023—they just optimize for different realities.
- Go + Cobra for quick utilities and a simple install story.
- Rust + Clap for performance-critical tooling and large-scale processing.
- TypeScript + Commander for Node-first ecosystems and integration-heavy CLIs.
Choose the one that matches how your users live. If you do that, you’ll spend less time firefighting environment mismatches and more time building a CLI people actually enjoy using.