Rust for JavaScript Developers
Introduction
When moving from JavaScript/TypeScript to Rust for CLI tools, understanding how to structure your project effectively is crucial. This guide will walk you through creating a well-organised Rust CLI application while highlighting similarities and differences compared to JavaScript patterns.
Getting Started
Creating a New Project
# Create a new binary applicationcargo new my_cli_tool
# Or with hyphens (common in Rust)cargo new my-cli-tool
# For a library + binary project (more modular)cargo new my_cli_tool --libJavaScript Comparison: Similar to
npm initorbun init, but Cargo automatically sets up a full project structure including Git. Rust strongly favours snake_case for package names internally, while JavaScript typically uses kebab-case.
Initial Project Structure
After creating a project with --lib, you’ll have:
my_cli_tool/├── Cargo.toml # Similar to package.json├── src/│ ├── lib.rs # Library code (shared functionality)│ └── main.rs # Entry point for the binaryBreaking Down a CLI Tool
Let’s structure a more complex CLI tool with multiple commands and concerns:
my_cli_tool/├── Cargo.toml├── src/│ ├── main.rs # Entry point (minimal)│ ├── lib.rs # Re-exports modules│ ├── cli/ # CLI-specific code│ │ ├── mod.rs # Module declaration│ │ ├── args.rs # Command-line argument handling│ │ └── commands/ # Individual command implementations│ │ ├── mod.rs # Commands module declaration│ │ ├── init.rs # "init" command implementation│ │ └── run.rs # "run" command implementation│ ├── core/ # Core functionality│ │ ├── mod.rs│ │ ├── config.rs # Configuration management│ │ └── utils.rs # Utility functions│ └── services/ # Domain-specific modules│ ├── mod.rs│ ├── api.rs # API client│ └── storage.rs # Storage layerJavaScript Comparison: This structure might look familiar to JavaScript developers. However, Rust requires explicit module declarations, whereas JavaScript relies on directory structure and implicit imports.
Key Files and Their Purpose
main.rs - Program Entry Point
The main.rs file should be minimal, focusing on:
- Argument parsing
- Command routing
- Error handling
- Calling into your library code
use my_cli_tool::{cli, core};
fn main() { // Parse command-line arguments let args = cli::args::parse();
// Set up logging/error handling if let Err(e) = run(args) { eprintln!("Error: {}", e); std::process::exit(1); }}
fn run(args: cli::args::Args) -> Result<(), Box<dyn std::error::Error>> { // Initialize configuration let config = core::config::load(&args.config_path)?;
// Route to appropriate command match args.command { cli::args::Command::Init(opts) => cli::commands::init::run(opts, config)?, cli::args::Command::Run(opts) => cli::commands::run::run(opts, config)?, // Other commands... }
Ok(())}JavaScript Comparison: This is more structured than many JavaScript CLI entry points. In JavaScript, you might have everything in one file or use a framework like Commander.js or Yargs that handles routing. In Rust, it’s common to write this routing logic yourself.
lib.rs - Module Declarations and Re-exports
The lib.rs file serves as the entry point for your library code:
// Declare modulespub mod cli;pub mod core;pub mod services;
// Re-export commonly used itemspub use core::config::Config;pub use core::error::Error;JavaScript Comparison: This pattern of re-exporting is similar to creating an
index.jsfile that re-exports from other modules, but Rust requires explicit module declarations.
Module Declaration (mod.rs)
Each directory typically includes a mod.rs file that declares submodules:
pub mod args;pub mod commands;
// Maybe re-export common itemspub use args::Args;JavaScript Comparison: This has no direct equivalent in JavaScript. While
index.jsfiles serve as directory entry points, Rust’s module system is more explicit, requiring you to declare submodules.
Module System: A Major Difference
JavaScript’s Approach
In JavaScript/TypeScript:
- Files are modules by default
- Imports are resolved by relative paths or package names
- Directory structure implicitly defines module hierarchy
- No need to declare modules or their relationships
// JavaScript - direct importsimport { runCommand } from "./commands/run.js";Rust’s Approach
In Rust:
- Modules must be explicitly declared with
mod - Privacy is the default; items must be explicitly marked as
pub - Module hierarchy follows a tree structure
- Re-exports are common to create a clean public API
// Rust - must declare modules firstmod commands; // Declares the existence of commands.rs or commands/mod.rs
// Then import from ituse commands::run::run_command;Best Practice Contrast: This explicit module declaration is diametrically opposed to JavaScript’s implicit file-based module system. In Rust, you need to think about your module structure up front.
Practical Module Organization
CLI Arguments Module
use clap::{Parser, Subcommand};
#[derive(Parser)]#[command(author, version, about)]pub struct Args { #[command(subcommand)] pub command: Command,
#[arg(short, long, default_value = "config.toml")] pub config_path: String,}
#[derive(Subcommand)]pub enum Command { Init(InitOptions), Run(RunOptions),}
#[derive(Parser)]pub struct InitOptions { #[arg(short, long)] pub force: bool,}
#[derive(Parser)]pub struct RunOptions { pub input: String,}
pub fn parse() -> Args { Args::parse()}JavaScript Comparison: This is much more type-safe than JavaScript equivalents. In TypeScript, you might define interfaces for your arguments, but they’re usually not as tightly integrated with the parsing logic.
Command Implementation Module
use crate::core::Config;use crate::services::api;
pub fn run(opts: super::super::args::RunOptions, config: Config) -> Result<(), Box<dyn std::error::Error>> { println!("Running with input: {}", opts.input);
// Use service modules let client = api::Client::new(&config.api_key); let result = client.process(&opts.input)?;
println!("Result: {}", result); Ok(())}JavaScript Comparison: This structured approach is similar to well-designed JavaScript applications, but Rust encourages this pattern from the start through its module system.
Handling Shared Types and Constants
In Rust, it’s common to define shared types in their own modules:
pub struct User { pub id: String, pub name: String,}
// In another fileuse crate::core::types::User;JavaScript Comparison: This is similar to having a
types.tsfile in TypeScript, but Rust’s types are always enforced at compile time and more deeply integrated with the language.
Best Practices for Rust CLI Projects
1. Separate Library and Binary Code
Keep main.rs thin and move most functionality to the library. This allows your code to be:
- Testable (binary code is harder to test)
- Reusable (others can use your library)
- Better organised (clearer separation of concerns)
JavaScript Contrast: Many JavaScript CLI tools mix CLI-specific code with core functionality, making testing harder.
2. Use the Facade Pattern
Create a simple public API that hides implementation details:
// Public API in lib.rspub fn initialize(path: &str) -> Result<(), Error> { // Implementation details inside internal::setup::initialize_config(path)}
// User just callsmy_cli_tool::initialize("./config.toml")?;JavaScript Comparison: This is similar to creating facade modules in JavaScript, but Rust’s privacy system makes it more enforceable.
3. Error Handling Patterns
Rust encourages propagating errors with the ? operator and custom error types:
use thiserror::Error;
#[derive(Error, Debug)]pub enum CliError { #[error("Configuration error: {0}")] Config(String),
#[error("API error: {0}")] Api(#[from] ApiError),}
fn process_file(path: &str) -> Result<(), CliError> { let config = parse_config(path).map_err(|e| CliError::Config(e.to_string()))?; api::call(&config)?; // ApiErrors automatically convert to CliError due to #[from] Ok(())}JavaScript Contrast: This is very different from JavaScript’s exception-based error handling. Rust errors are values that are explicitly passed, not exceptions that bubble up implicitly.
4. Use Tests Directories
In Rust, tests are often organised in a parallel structure:
src/├── model.rs└── utils.rstests/├── model_tests.rs└── utils_tests.rsOr with inline tests:
// At the bottom of src/utils.rs#[cfg(test)]mod tests { use super::*;
#[test] fn test_format_output() { assert_eq!(format_output("hello"), "HELLO"); }}JavaScript Comparison: This is actually similar to good JavaScript testing practices, though Rust’s testing framework is built into the language.
Where Rust and JavaScript Patterns Diverge
1. Dependency Management
JavaScript: Has flat dependencies with potential duplication, heavily relies on tiny single-purpose packages.
Rust: Prefers fewer, more complete crates with minimal dependencies. The standard library is comprehensive.
Best Practice Contrast: In JavaScript it’s common to pull in many tiny packages for simple tasks. In Rust, this approach is discouraged - prefer using the standard library when possible and choose crates with minimal dependency trees.
2. Modularity Approach
JavaScript: Often creates many small files, sometimes one per function/class.
Rust: Generally has fewer, larger module files that group related functionality.
Best Practice Contrast: Where TypeScript might advocate for many tiny files, Rust often finds this creates excessive complexity with module declarations. Aim for a middle ground.
3. Object-Oriented vs. Trait-Based
JavaScript/TypeScript: Uses classes, inheritance, and objects for organisation.
Rust: Uses traits, composition, and enums instead of classes.
Best Practice Contrast: Instead of creating class hierarchies like in TypeScript, in Rust you’ll define behavior through traits and compose functionality rather than inherit it.
4. State Management
JavaScript: Often relies on object mutability and shared state.
Rust: Prefers immutability, ownership, and passing values explicitly.
Best Practice Contrast: JavaScript often freely shares state between modules. Rust enforces clear ownership, requiring you to think about who “owns” each piece of data.
Practical Example: Application Structure
Here’s how a complete Cargo.toml might look:
[package]name = "my_cli_tool"version = "0.1.0"edition = "2021"authors = ["Your Name <you@example.com>"]description = "A CLI tool for managing widgets"
# Both a library and a binary[lib]path = "src/lib.rs"
[[bin]]name = "my-cli"path = "src/main.rs"
[dependencies]clap = { version = "4.4", features = ["derive"] }serde = { version = "1.0", features = ["derive"] }serde_json = "1.0"thiserror = "1.0"log = "0.4"env_logger = "0.10"anyhow = "1.0"Conclusion
Building a Rust CLI tool requires a different mental model than JavaScript, particularly around module organisation, error handling, and type management. However, many principles of good software design remain the same:
- Separation of concerns
- Small, focused functions
- Clear interfaces between modules
- Comprehensive error handling
- Testability
Rust enforces many of these principles through its type system, ownership model, and module system, making it easier to maintain large codebases in the long run, even if the initial learning curve is steeper than JavaScript.