02. Variables and Types
📋 Jump to Takeaways🎁 What if every variable you created was immutable by default — you literally couldn't change it after assignment? Why would a language force that constraint on you?
Rust makes immutability the default because mutable state is the root of most bugs in concurrent programs. You opt in to mutation explicitly, making your intent clear to both the compiler and anyone reading your code.
let — Immutable by Default
When you declare a variable with let, it cannot be reassigned:
fn main() {
let x = 5;
println!("x is: {}", x);
// x = 10; // ERROR: cannot assign twice to immutable variable
}This isn't a limitation — it's a guarantee. When you see let x = 5, you know x is 5 for its entire lifetime. No hunting through code to find where it changed.
let mut — Opting Into Mutation
When you need a variable to change, add mut:
fn main() {
let mut counter = 0;
println!("counter: {}", counter); // counter: 0
counter += 1;
println!("counter: {}", counter); // counter: 1
counter += 1;
println!("counter: {}", counter); // counter: 2
}The mut keyword is a signal. It tells readers: "this value will change." Every mutation point in your program is now visible at the declaration site.
Shadowing
You can redeclare a variable with the same name using a new let. This is called shadowing — the new binding hides the previous one:
fn main() {
let x = 5;
println!("x: {}", x); // x: 5
let x = x + 10;
println!("x: {}", x); // x: 15
let x = x * 2;
println!("x: {}", x); // x: 30
}Each let x creates a brand new variable. The old one still exists in memory but is no longer accessible by that name.
Shadowing differs from mut because you can change the type:
fn main() {
let spaces = " "; // &str
let spaces = spaces.len(); // usize — different type, same name
println!("spaces: {}", spaces); // spaces: 3
}With mut, changing the type would be a compile error. Shadowing lets you transform a value and reuse a meaningful name.
Type Inference
Rust's compiler infers types from context. You don't always need annotations:
fn main() {
let x = 42; // inferred as i32
let y = 3.14; // inferred as f64
let active = true; // inferred as bool
let name = "Rust"; // inferred as &str
println!("{} {} {} {}", x, y, active, name);
// Output: 42 3.14 true Rust
}The compiler chooses i32 for integers and f64 for floats when there's no other context. Inference keeps code concise without sacrificing type safety.
Explicit Type Annotations
When inference isn't enough or you want a specific type, annotate with a colon:
fn main() {
let small: i8 = 127; // 8-bit signed
let big: i64 = 9_000_000_000; // 64-bit signed
let precise: f32 = 3.14; // 32-bit float (not the default f64)
let byte: u8 = 255; // 8-bit unsigned
println!("small: {}", small); // small: 127
println!("big: {}", big); // big: 9000000000
println!("precise: {}", precise); // precise: 3.14
println!("byte: {}", byte); // byte: 255
}Annotations are required when the compiler can't determine the type — for example, when parsing a string into a number.
Scalar Types — Integers
Rust provides integers at every power-of-two size, signed and unsigned:
| Signed | Unsigned | Bits | Range (signed) |
|---|---|---|---|
| i8 | u8 | 8 | -128 to 127 |
| i16 | u16 | 16 | -32,768 to 32,767 |
| i32 | u32 | 32 | -2B to 2B |
| i64 | u64 | 64 | huge |
| i128 | u128 | 128 | enormous |
| isize | usize | arch | pointer-sized |
fn main() {
let a: u8 = 255; // max for u8
let b: i16 = -1000; // negative needs signed
let c: usize = 100; // used for indexing
println!("a: {}, b: {}, c: {}", a, b, c);
// Output: a: 255, b: -1000, c: 100
}isize and usize match your CPU architecture (64-bit on modern machines). Use usize for array indices and collection lengths.
Scalar Types — Floats, Bool, Char
fn main() {
// Floating point
let f1: f64 = 2.718281828; // double precision (default)
let f2: f32 = 3.14; // single precision
// Boolean
let is_rust: bool = true;
let is_slow: bool = false;
// Character — 4 bytes, supports Unicode
let letter: char = 'A';
let emoji: char = '🦀';
let chinese: char = '中';
println!("{} {} {} {} {} {}", f1, f2, is_rust, is_slow, letter, emoji);
// Output: 2.718281828 3.14 true false A 🦀
println!("char: {}", chinese); // char: 中
}char in Rust is 4 bytes and represents a Unicode scalar value — not just ASCII. This means emoji and international characters are first-class citizens.
Compound Types — Tuples
Tuples group multiple values of different types into one compound value with a fixed length:
fn main() {
let point: (i32, i32) = (10, 20);
let mixed: (i32, f64, bool) = (1, 3.14, true);
// Access by index
println!("x: {}, y: {}", point.0, point.1);
// Output: x: 10, y: 20
// Destructuring
let (a, b, c) = mixed;
println!("a: {}, b: {}, c: {}", a, b, c);
// Output: a: 1, b: 3.14, c: true
}Tuple indices start at 0 and use dot notation. Destructuring lets you unpack all elements into individual variables at once.
Compound Types — Arrays
Arrays hold multiple values of the same type with a fixed length:
fn main() {
let numbers: [i32; 5] = [1, 2, 3, 4, 5];
let zeros = [0; 3]; // [0, 0, 0] — repeat syntax
println!("first: {}", numbers[0]); // first: 1
println!("last: {}", numbers[4]); // last: 5
println!("zeros: {:?}", zeros); // zeros: [0, 0, 0]
println!("length: {}", numbers.len()); // length: 5
}Arrays are stack-allocated and fixed-size. If you access an index out of bounds, Rust panics at runtime rather than allowing undefined behavior. For dynamic-size collections, you'll use Vec<T> later.
const vs let
const values are compile-time constants. They differ from let in important ways:
const MAX_SCORE: u32 = 100;
const PI: f64 = 3.14159265358979;
fn main() {
let current_score: u32 = 85;
println!("Score: {} / {}", current_score, MAX_SCORE);
// Output: Score: 85 / 100
println!("Pi: {}", PI);
// Output: Pi: 3.14159265358979
}Key differences:
constrequires a type annotation — alwaysconstmust be a compile-time computable value — no function callsconstcan be declared in any scope, including globalconstis inlined everywhere it's used — no memory address
Use const for values that are truly fixed for the entire program. Use let for everything else.
String Types — &str vs String
Rust has two primary string types. This is a brief introduction — strings get their own deep-dive later.
fn main() {
// &str — string slice, immutable, usually hardcoded
let greeting: &str = "hello";
// String — heap-allocated, growable, owned
let mut name = String::from("Rust");
name.push_str(" language");
println!("{}, {}!", greeting, name);
// Output: hello, Rust language!
println!("length: {}", name.len()); // length: 13
}&str is a borrowed reference to string data — cheap to pass around. String owns its data on the heap and can grow. You'll understand the ownership distinction fully when you reach the ownership lesson.
Key Takeaways
letcreates immutable bindings — reassignment is a compile errorlet mutexplicitly opts into mutation- Shadowing (
let x = ...again) creates a new variable and can change the type - Rust infers types but you can annotate with
: Type - Integer types:
i8–i128(signed),u8–u128(unsigned) - Float types:
f32(single),f64(double, default) boolistrue/false;charis 4-byte Unicode- Tuples hold mixed types with fixed length; arrays hold same type with fixed length
constis compile-time, requires type annotation, and lives globally&stris a borrowed string slice;Stringis an owned, growable string
🎁 Next up: you'll write your own functions and discover that Rust doesn't need a return keyword — the last expression in a function is the return value. This expression-based design changes how you think about code flow entirely.