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

TypeSizeRangeExample
i88-bit-128 to 127let x: i8 = -50;
i1616-bit-32,768 to 32,767let x: i16 = 1000;
i3232-bit±2 billionlet x: i32 = 42;
i6464-bit±9 quintillionlet x: i64 = 1_000_000;
u88-bit0 to 255let x: u8 = 200;
u1616-bit0 to 65,535let x: u16 = 50000;
u3232-bit0 to 4 billionlet x: u32 = 42;
u6464-bit0 to 18 quintillionlet x: u64 = 1_000_000;
usizePointer sizeDepends on platformlet 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
&strString
OwnershipBorrowed (reference)Owned
MutableNoYes (with mut)
Where storedStack or binaryHeap
Use forFunction parameters, literalsBuilding/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

ConceptSyntaxExample
Immutable variablelet x = value;let name = "Alex";
Mutable variablelet mut x = value;let mut count = 0;
Constantconst NAME: Type = value;const MAX: u32 = 100;
Type annotationlet x: Type = value;let age: u32 = 25;
Shadowinglet x = new_value;let x = x + 1;
Functionfn name(param: Type) -> Returnfn add(a: i32, b: i32) -> i32
Return valueLast expression (no semicolon)a + b
String literal"text"let s = "hello";
Owned stringString::from("text")let s = String::from("hello");
Formatformat!("{}", var)format!("Hello, {}", name)

Source Code

View source code on GitHub →

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.

Next: Rust Tutorial #4: Ownership — The Key Concept