In the previous tutorial, we installed Rust and wrote “Hello, world!”. Now let’s learn the building blocks — variables, types, and functions.
If you know Kotlin, Python, or JavaScript, most of this will feel familiar. But Rust has a few surprises — especially around mutability and shadowing.
Variables: let and mut
Immutable by Default
In Rust, variables are immutable by default. You cannot change them after creation:
let name = "Alex";
name = "Sam"; // ERROR: cannot assign twice to immutable variable
This is the opposite of most languages. In Kotlin, val is immutable and var is mutable. In Rust, let is immutable and let mut is mutable.
Mutable Variables
Add mut to make a variable changeable:
let mut count = 0;
count += 1; // OK — count is mutable
count += 1;
println!("Count: {}", count); // Count: 2
Why Immutable by Default?
Immutable variables prevent bugs. If a value should never change, making it immutable means the compiler catches accidental changes for you. Use mut only when you need to change the value.
// Good practice: only add mut when you need it
let name = "Alex"; // Never changes — immutable
let mut score = 0; // Will change — mutable
let total_items = 42; // Never changes — immutable
let mut search_query = String::new(); // Will change — mutable
Constants
For values that are truly fixed at compile time, use const:
const MAX_SCORE: u32 = 100;
const PI: f64 = 3.14159;
const APP_NAME: &str = "Task Manager";
Constants vs variables:
- Constants MUST have a type annotation (
u32,f64,&str) - Constants are set at compile time, not runtime
- Constants are ALWAYS immutable (no
mut) - Convention: SCREAMING_SNAKE_CASE
Data Types
Integers
| Type | Size | Range | Example |
|---|---|---|---|
i8 | 8-bit | -128 to 127 | let x: i8 = -50; |
i16 | 16-bit | -32,768 to 32,767 | let x: i16 = 1000; |
i32 | 32-bit | ±2 billion | let x: i32 = 42; |
i64 | 64-bit | ±9 quintillion | let x: i64 = 1_000_000; |
u8 | 8-bit | 0 to 255 | let x: u8 = 200; |
u16 | 16-bit | 0 to 65,535 | let x: u16 = 50000; |
u32 | 32-bit | 0 to 4 billion | let x: u32 = 42; |
u64 | 64-bit | 0 to 18 quintillion | let x: u64 = 1_000_000; |
usize | Pointer size | Depends on platform | let x: usize = 10; |
Default integer type is i32 — good balance of range and performance.
usize is used for indexing and sizes. It is 64-bit on 64-bit systems, 32-bit on 32-bit systems.
Underscores for readability:
let million = 1_000_000; // Same as 1000000
let binary = 0b1010_1010; // Binary literal
let hex = 0xFF; // Hex literal
Floating Point
let pi = 3.14; // f64 (default)
let pi_f32: f32 = 3.14; // f32 (explicit)
f64 is the default because it is more precise and nearly as fast as f32 on modern CPUs.
Boolean
let is_active = true;
let is_admin: bool = false;
Characters
let letter = 'A'; // Single character (single quotes)
let emoji = '🦀'; // Characters are Unicode, so emojis work
let chinese = '中';
Characters use single quotes. Strings use double quotes. Don’t mix them.
Strings — Two Types
This is where Rust differs from most languages. There are two string types:
// &str — string slice (borrowed, fixed-size, in memory or binary)
let greeting: &str = "Hello";
// String — owned string (heap-allocated, growable)
let mut name = String::from("Alex");
name.push_str(" Smith");
println!("{}", name); // Alex Smith
&str | String | |
|---|---|---|
| Ownership | Borrowed (reference) | Owned |
| Mutable | No | Yes (with mut) |
| Where stored | Stack or binary | Heap |
| Use for | Function parameters, literals | Building/modifying strings |
For now, use &str for text that doesn’t change and String for text you build or modify. We will explain ownership in Tutorial #4.
String Formatting
let name = "Alex";
let age = 25;
// format! — creates a String
let message = format!("{} is {} years old", name, age);
// println! — prints directly
println!("{} is {} years old", name, age);
// Display vs Debug formatting
let numbers = vec![1, 2, 3];
println!("Display: not available for Vec");
println!("Debug: {:?}", numbers); // [1, 2, 3]
println!("Pretty: {:#?}", numbers); // Formatted with newlines
// Named parameters
println!("{name} is {age} years old"); // Rust 2024 edition
{} uses Display formatting (human-readable). {:?} uses Debug formatting (programmer-readable). Most types support Debug. Only some support Display.
Tuples
Group multiple values of different types:
let person: (String, i32, bool) = (String::from("Alex"), 25, true);
// Access by index
println!("Name: {}", person.0);
println!("Age: {}", person.1);
// Destructuring
let (name, age, active) = person;
println!("{} is {} and active: {}", name, age, active);
Arrays
Fixed-size collection of the same type:
let scores: [i32; 5] = [1, 2, 3, 4, 5];
let zeros = [0; 10]; // [0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
println!("First: {}", scores[0]);
println!("Length: {}", scores.len());
Arrays have a fixed size known at compile time. For growable lists, use Vec (Tutorial #9).
Type Inference
Rust can figure out types without you writing them:
let x = 42; // Rust infers i32
let pi = 3.14; // Rust infers f64
let active = true; // Rust infers bool
let name = "Alex"; // Rust infers &str
But sometimes you need to be explicit:
let guess: u32 = "42".parse().expect("Not a number");
// Without : u32, Rust doesn't know what type to parse into
Rule of thumb: Let Rust infer types when it can. Add explicit types when the compiler asks you to.
Type Aliases
Create shorter names for complex types:
type UserId = u64;
type Score = f32;
let user_id: UserId = 12345;
let player_score: Score = 98.5;
Shadowing
You can declare a new variable with the same name. The new one “shadows” the old one:
let x = 5;
let x = x + 1; // x is now 6 (new variable, old one is gone)
let x = x * 2; // x is now 12
println!("x = {}", x); // x = 12
Shadowing is NOT mutation. Each let x creates a new variable. The old one is dropped.
Shadowing Can Change Types
let input = "42"; // &str
let input = input.parse::<i32>().unwrap(); // i32
// With mut, you CAN'T change types:
let mut value = "hello";
value = 42; // ERROR: expected &str, found integer
This is useful for converting data — read as string, parse to number, use the same name.
Functions
Basic Function
fn greet(name: &str) {
println!("Hello, {}!", name);
}
fn main() {
greet("Alex");
greet("Sam");
}
Functions with Return Values
fn add(a: i32, b: i32) -> i32 {
a + b // No semicolon = return value
}
fn is_adult(age: u32) -> bool {
age >= 18
}
Important: The last expression WITHOUT a semicolon is the return value. Adding a semicolon changes the return type to () (unit/void):
fn add(a: i32, b: i32) -> i32 {
a + b // Returns the sum (no semicolon)
}
fn add_broken(a: i32, b: i32) -> i32 {
a + b; // ERROR: semicolon makes this a statement, not a return
}
Early Return
Use return for early exits:
fn divide(a: f64, b: f64) -> f64 {
if b == 0.0 {
return 0.0; // Early return
}
a / b // Normal return (last expression)
}
Functions with Multiple Return Values
Use tuples:
fn min_max(numbers: &[i32]) -> (i32, i32) {
let min = *numbers.iter().min().unwrap();
let max = *numbers.iter().max().unwrap();
(min, max)
}
fn main() {
let data = [3, 1, 4, 1, 5, 9, 2, 6];
let (min, max) = min_max(&data);
println!("Min: {}, Max: {}", min, max);
}
Expressions vs Statements
This is different from most languages. In Rust, almost everything is an expression that returns a value:
// if/else is an expression — it returns a value
let status = if score > 50 { "pass" } else { "fail" };
// Blocks are expressions
let result = {
let x = 5;
let y = 10;
x + y // This is the block's return value
};
println!("Result: {}", result); // 15
Type Conversions
Rust does NOT do implicit type conversion. You must be explicit:
let x: i32 = 42;
let y: f64 = x as f64; // Cast i32 to f64
let z: u8 = x as u8; // Cast i32 to u8 (may truncate!)
let text = "42";
let number: i32 = text.parse().unwrap(); // Parse string to number
let number = 42;
let text = number.to_string(); // Number to string
No implicit int → float or String → number conversions. The compiler forces you to be explicit about every conversion. This prevents subtle bugs.
Common Mistakes
Mistake 1: Forgetting mut
let count = 0;
count += 1; // ERROR: cannot assign twice to immutable variable
// Fix: let mut count = 0;
Mistake 2: Wrong String Type
fn greet(name: String) { ... } // Takes ownership of the String
greet("Alex"); // ERROR: "Alex" is &str, not String
// Fix: accept &str instead
fn greet(name: &str) { ... }
greet("Alex"); // OK
Mistake 3: Missing Return (Semicolon)
fn double(x: i32) -> i32 {
x * 2; // ERROR: expected i32, found ()
}
// Fix: remove the semicolon
fn double(x: i32) -> i32 {
x * 2
}
Mistake 4: Integer Overflow
let x: u8 = 255;
let y = x + 1; // PANIC in debug mode, wraps in release mode
// Fix: use a larger type or check for overflow
let y = x.checked_add(1); // Returns None instead of panicking
Quick Reference
| Concept | Syntax | Example |
|---|---|---|
| Immutable variable | let x = value; | let name = "Alex"; |
| Mutable variable | let mut x = value; | let mut count = 0; |
| Constant | const NAME: Type = value; | const MAX: u32 = 100; |
| Type annotation | let x: Type = value; | let age: u32 = 25; |
| Shadowing | let x = new_value; | let x = x + 1; |
| Function | fn name(param: Type) -> Return | fn add(a: i32, b: i32) -> i32 |
| Return value | Last expression (no semicolon) | a + b |
| String literal | "text" | let s = "hello"; |
| Owned string | String::from("text") | let s = String::from("hello"); |
| Format | format!("{}", var) | format!("Hello, {}", name) |
Source Code
Related Tutorials
- Rust Tutorial #2: Installation — setting up Rust
- Rust Tutorial #1: Why Learn Rust — why Rust matters
What’s Next?
In the next tutorial, we tackle the BIG one — Ownership. This is the concept that makes Rust unique and the reason Rust doesn’t need a garbage collector. It is the hardest part of learning Rust, but once you get it, everything clicks.