Skip to content

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

Terminal window
# Create a new binary application
cargo 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 --lib

JavaScript Comparison: Similar to npm init or bun 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 binary

Breaking 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 layer

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

  1. Argument parsing
  2. Command routing
  3. Error handling
  4. Calling into your library code
src/main.rs
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:

src/lib.rs
// Declare modules
pub mod cli;
pub mod core;
pub mod services;
// Re-export commonly used items
pub use core::config::Config;
pub use core::error::Error;

JavaScript Comparison: This pattern of re-exporting is similar to creating an index.js file 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:

src/cli/mod.rs
pub mod args;
pub mod commands;
// Maybe re-export common items
pub use args::Args;

JavaScript Comparison: This has no direct equivalent in JavaScript. While index.js files 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 imports
import { 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 first
mod commands; // Declares the existence of commands.rs or commands/mod.rs
// Then import from it
use 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

src/cli/args.rs
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

src/cli/commands/run.rs
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:

src/core/types.rs
pub struct User {
pub id: String,
pub name: String,
}
// In another file
use crate::core::types::User;

JavaScript Comparison: This is similar to having a types.ts file 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.rs
pub fn initialize(path: &str) -> Result<(), Error> {
// Implementation details inside
internal::setup::initialize_config(path)
}
// User just calls
my_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.rs
tests/
├── model_tests.rs
└── utils_tests.rs

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