Coming to Rust from JavaScript/TypeScript
You've spent months building apps with JavaScript or TypeScript. You know the rhythm: write code, run it, see the error in the console, fix it, repeat. You're comfortable with objects that change shape, garbage collection that cleans up behind you, and npm packages that just work. Now you open a Rust project. You type a few lines, hit run, and the compiler stops you dead. It won't let the program start. It points at a variable and says you can't use it there. You haven't even launched the app yet, and the tool is already fighting you.
This isn't a bug. The compiler is doing exactly what it was designed to do: catch the crash before it happens. Rust shifts the burden of correctness from runtime to compile time. You trade the flexibility of dynamic typing for guarantees about memory safety and performance. The learning curve is steep, but the payoff is code that runs fast and doesn't leak memory.
Tooling and project setup
JavaScript relies on npm or yarn for package management and a separate build tool like Webpack or Vite. Rust bundles everything into cargo. cargo manages dependencies, builds the project, runs tests, generates documentation, and publishes crates. It's a single command for the entire workflow.
Install the Rust toolchain with rustup. This installs rustc, cargo, and keeps your toolchain up to date.
curl --proto '=https' --tlsv1.2 https://sh.rustup.rs -sSf | sh
Create a new project with cargo new. This generates a directory structure with a Cargo.toml manifest and a src/main.rs entry point.
cargo new my_project
cd my_project
cargo run
Run tests with cargo test. The command compiles your code in test mode, executes all functions marked with #[test], and reports results.
cargo test
Run cargo fmt to format your code. The community agrees on a single style. Don't argue about indentation or brace placement. Run the formatter and move on to logic.
Data structures and immutability
JavaScript objects are bags of properties. You can add, remove, or change keys at any time. Rust uses struct for named collections of fields. The shape is fixed at compile time. You can't add a field that doesn't exist, and you can't access a field that isn't defined.
/// Defines a user with a fixed shape.
struct User {
name: String,
age: u32,
}
fn main() {
// Create a user instance.
let user = User {
name: String::from("Alice"),
age: 25,
};
// Access fields by name.
println!("User: {}", user.name);
}
Variables are immutable by default. If you try to reassign a variable, the compiler rejects the code. You must declare the variable with mut to allow mutation. This prevents accidental state changes that are hard to track in large codebases.
fn main() {
let mut count = 0;
count += 1; // Allowed because count is mutable.
let name = String::from("Bob");
// name = String::from("Alice"); // Error: cannot assign twice to immutable variable.
}
Use enum when a value can be one of several distinct variants. JavaScript has no direct equivalent. TypeScript enums exist but are often just numbers. Rust enums are algebraic data types. Each variant can carry its own data. This replaces the pattern of checking a type field in an object.
/// Represents a shape that can be a circle or a rectangle.
enum Shape {
Circle { radius: f64 },
Rectangle { width: f64, height: f64 },
}
/// Calculates the area of a shape.
fn area(shape: &Shape) -> f64 {
match shape {
Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
Shape::Rectangle { width, height } => width * height,
}
}
Error handling without exceptions
JavaScript uses try/catch blocks to handle errors. You throw an exception, and it bubbles up until something catches it. If nothing catches it, the process crashes. Rust has no exceptions. Functions return a Result<T, E> enum to indicate success or failure. The caller must handle both cases. The compiler enforces this. If you ignore the error, the code won't compile.
/// Parses a string as an integer.
fn parse_age(input: &str) -> Result<u32, String> {
// Attempt to parse. Returns Err if the string is invalid.
input.parse::<u32>().map_err(|e| format!("Invalid age: {}", e))
}
fn main() {
// Handle the Result with pattern matching.
match parse_age("25") {
Ok(age) => println!("Age: {}", age),
Err(e) => println!("Error: {}", e),
}
}
Use the ? operator to propagate errors. It's cleaner than manual match when you just want to return the error up the call stack. If the value is Ok, it extracts the inner value. If it's Err, it returns early from the function.
/// Reads a config file and returns the contents.
fn read_config() -> Result<String, std::io::Error> {
// The ? operator propagates errors automatically.
std::fs::read_to_string("config.json")
}
JavaScript uses null and undefined to represent missing values. Rust has no null. Use Option<T> instead. Option is an enum with Some(T) and None variants. The compiler forces you to handle the None case. This eliminates null pointer exceptions.
/// Finds a user by ID. Returns None if not found.
fn find_user(id: u32) -> Option<String> {
if id == 1 {
Some(String::from("Alice"))
} else {
None
}
}
fn main() {
// Handle the Option explicitly.
match find_user(1) {
Some(name) => println!("Found: {}", name),
None => println!("Not found"),
}
}
Use let _ = value to discard a result. It signals to readers that you considered the value and chose to drop it intentionally. This suppresses the warning about unused values without hiding a mistake.
Ownership and borrowing
JavaScript relies on a garbage collector. The runtime tracks references and frees memory when nothing points to an object. Rust has no garbage collector. It uses ownership. Every value has exactly one owner. When the owner goes out of scope, the value is dropped immediately. This gives you predictable performance and zero-cost abstractions.
When you assign a value to a new variable, ownership moves. The original variable is no longer valid. This is called a move.
fn main() {
let s1 = String::from("hello");
let s2 = s1; // s1 is moved to s2.
// println!("{}", s1); // Error: E0382 use of moved value.
println!("{}", s2); // s2 owns the string now.
}
If you need to use the value in multiple places, pass a reference. A reference borrows the value without taking ownership. You can have multiple immutable references, or one mutable reference, but not both at the same time. This rule prevents data races.
fn main() {
let mut data = vec![1, 2, 3];
// Borrow mutably.
let ref_mut = &mut data;
ref_mut.push(4);
// Borrow immutably after the mutable borrow ends.
let ref_imm = &data;
println!("Length: {}", ref_imm.len());
}
If you try to borrow mutably while an immutable borrow exists, the compiler rejects the code with E0502. This error protects you from reading data while it's being modified.
fn main() {
let mut numbers = vec![1, 2, 3];
let first = &numbers[0]; // Immutable borrow starts.
// numbers.push(4); // Error: E0502 cannot borrow as mutable.
println!("First: {}", first); // Immutable borrow ends here.
}
Lifetimes ensure references don't outlive the data they point to. You usually don't need to write lifetime annotations. The compiler infers them. When you see a lifetime error, it means you're trying to use a reference after the data is gone.
/// Returns the longer of two string slices.
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
Concurrency and async
JavaScript uses a single-threaded event loop. You write async functions, and the runtime schedules callbacks. Rust supports true multi-threading. You can spawn threads that run in parallel on multiple cores. Rust also has async/await, but it requires a runtime like tokio or async-std.
The syntax looks similar to JavaScript, but the execution model is different. Rust's async tasks are lightweight and scheduled by the runtime, not the OS. You get the flexibility of async with the safety of the borrow checker. The compiler ensures you don't hold references across await points in ways that could cause data races.
use std::thread;
use std::time::Duration;
fn main() {
// Spawn a thread that runs concurrently.
let handle = thread::spawn(|| {
thread::sleep(Duration::from_secs(1));
"Done"
});
// Wait for the thread to finish.
let result = handle.join().unwrap();
println!("Thread result: {}", result);
}
Pitfalls and compiler errors
The borrow checker is the biggest hurdle. You'll see errors that feel arbitrary at first. Read the error message carefully. It usually tells you exactly what to fix. Common errors include E0382 (use of moved value), E0502 (cannot borrow as mutable because it is also borrowed as immutable), and E0597 (borrowed value does not live long enough).
When you see E0382, check if you're trying to use a value after passing it to a function. Clone the value or pass a reference. When you see E0502, check if you're holding a reference while mutating the data. Restructure the code to separate reads and writes. When you see E0597, check if a reference is outliving the data it points to. Extend the lifetime of the data or return an owned value.
Don't fight the compiler. It's guiding you toward safe code. If you can't make the compiler happy, your code likely has a bug. Trust the borrow checker. It usually has a point.
Decision guide
Use cargo for project management, building, testing, and documentation. It replaces npm and yarn with a single tool that handles the entire workflow.
Use struct when you need a named collection of fields with a fixed shape. It replaces plain objects and classes.
Use enum when a value can be one of several distinct variants. It replaces union types and tagged objects.
Use Result<T, E> when a function can fail and you need to return an error value. It replaces try/catch blocks.
Use Option<T> when a value might be missing. It replaces null and undefined.
Use Vec<T> when you need a growable array. It replaces JavaScript arrays.
Use String when you need an owned, mutable string. It replaces string primitives when you need to modify the content.
Use &str when you need a borrowed string slice. It's more efficient than String for passing strings around.
Use mut when you need to change a variable. Declare variables as immutable by default to prevent accidental mutation.
Use the ? operator to propagate errors. It's cleaner than manual matching when you just want to return the error.
Pick the type that matches your data. The compiler will enforce the rest.