Series: Learn Rust from Scratch | Post 2 of 11
Introduction
In the last post we installed Rust and wrote our first program. Now it's time to build the real foundation: variables and data types.
Rust's approach to variables is different from most languages you may have used before. Variables are immutable by default, the type system is strict and explicit, and there's a clever feature called shadowing that gives you flexibility without sacrificing safety.
Let's dig in.
Variables and Immutability
In Rust, you declare a variable using the let keyword.
fn main() {
let x = 5;
println!("The value of x is: {}", x);
}
Output:
The value of x is: 5
Now try changing the value of x:
fn main() {
let x = 5;
x = 10; // ❌ This will cause a compile error!
println!("{}", x);
}
Compiler error:
error[E0384]: cannot assign twice to immutable variable `x`
--> src/main.rs:3:5
|
2 | let x = 5;
| - first assignment to `x`
3 | x = 10;
| ^^^^^^ cannot assign twice to immutable variable
By default, variables in Rust are immutable. Once you bind a value to a name, it cannot change. This is intentional — it helps prevent a whole class of bugs.
Making Variables Mutable with mut
If you need a variable to change, add the mut keyword:
fn main() {
let mut score = 0; // mutable variable
println!("Score: {}", score);
score = 100; // ✅ Allowed because of `mut`
println!("Score: {}", score);
score += 50; // Add 50 to score
println!("Final Score: {}", score);
}
Output:
Score: 0
Score: 100
Final Score: 150
Best practice: Start with immutable variables. Only add
mutwhen you actually need to change the value. This makes your code easier to reason about.
Constants
Constants are similar to immutable variables but with important differences:
// Constants use `const`, not `let`
// The type MUST always be annotated
// They can be declared in any scope, including global scope
const MAX_POINTS: u32 = 100_000;
const PI: f64 = 3.14159;
fn main() {
println!("Max points: {}", MAX_POINTS);
println!("Pi is approximately: {}", PI);
}
Key differences between let and const:
| Feature | let |
const |
|---|---|---|
| Mutability | Immutable by default | Always immutable |
| Type annotation | Optional | Required |
| Value | Any expression | Must be a constant expression |
| Scope | Function-level | Any scope, including global |
| Naming convention | snake_case |
SCREAMING_SNAKE_CASE |
Note: 100_000 is the same as 100000. Rust allows underscores in numeric literals for readability.
Shadowing
Shadowing is a Rust feature that lets you re-declare a variable with the same name:
fn main() {
let x = 5;
println!("x is: {}", x); // 5
// Shadow x with a new value
let x = x + 1;
println!("x is: {}", x); // 6
// Shadow x again inside a new scope
{
let x = x * 2;
println!("x inside block is: {}", x); // 12
}
// The inner shadow is gone; x is back to 6
println!("x is: {}", x); // 6
}
Output:
x is: 5
x is: 6
x inside block is: 12
x is: 6
Shadowing is also powerful because it lets you change the type of a variable:
fn main() {
// First, spaces is a String
let spaces = " ";
println!("spaces (string): '{}'", spaces);
// Shadow it with a usize (number)
let spaces = spaces.len();
println!("spaces (number): {}", spaces);
}
Output:
spaces (string): ' '
spaces (number): 3
With mut, this wouldn't be possible — you can't change a variable's type. Shadowing creates a brand new variable with the same name.
Data Types
Rust is statically typed: every variable must have a known type at compile time. Rust can often infer the type, but sometimes you need to annotate it.
Scalar Types
Scalar types represent a single value. Rust has four scalar types:
1. Integers
Integers are whole numbers (no decimal point).
fn main() {
let a: i8 = -128; // 8-bit signed: -128 to 127
let b: i16 = -32000; // 16-bit signed
let c: i32 = -2_000_000; // 32-bit signed (default integer type)
let d: i64 = -9_000_000_000; // 64-bit signed
let e: i128 = -170_000_000_000_000_000_000; // 128-bit signed
let f: u8 = 255; // 8-bit unsigned: 0 to 255
let g: u16 = 65535; // 16-bit unsigned
let h: u32 = 4_000_000; // 32-bit unsigned
let i: u64 = 18_000_000_000; // 64-bit unsigned
let j: usize = 42; // pointer-sized unsigned (for indexing)
println!("i32 example: {}", c);
println!("u8 example: {}", f);
println!("usize example: {}", j);
}
Rule of thumb: Use
i32for general integers. Useusizefor indexing into arrays or collections.
Different integer literal formats:
fn main() {
let decimal = 98_222; // Decimal
let hex = 0xff; // Hexadecimal → 255
let octal = 0o77; // Octal → 63
let binary = 0b1111_0000; // Binary → 240
let byte = b'A'; // Byte (u8 only) → 65
println!("{} {} {} {} {}", decimal, hex, octal, binary, byte);
}
Output:
98222 255 63 240 65
2. Floating-Point Numbers
fn main() {
let x: f32 = 3.14; // 32-bit float (single precision)
let y: f64 = 3.14159265358979; // 64-bit float (double precision, default)
// Basic arithmetic
let sum = y + 1.0;
let difference = y - 1.0;
let product = y * 2.0;
let quotient = y / 2.0;
let remainder = 10.5 % 3.0; // modulo
println!("f64 value: {}", y);
println!("sum: {}", sum);
println!("product: {}", product);
println!("remainder: {}", remainder);
}
Tip: Always use
f64unless you have a specific reason to usef32. It's more precise and often just as fast on modern hardware.
3. Booleans
fn main() {
let is_active: bool = true;
let is_done = false; // type inferred as bool
println!("Active: {}", is_active);
println!("Done: {}", is_done);
// Booleans are used in conditions
if is_active {
println!("The system is running.");
}
// Boolean operations
let both = is_active && is_done; // AND
let either = is_active || is_done; // OR
let not_active = !is_active; // NOT
println!("Both true? {}", both);
println!("Either true? {}", either);
println!("Not active? {}", not_active);
}
Output:
Active: true
Done: false
The system is running.
Both true? false
Either true? true
Not active? false
4. Characters
Rust's char type is a Unicode scalar value — meaning it can represent far more than just ASCII letters.
fn main() {
let letter: char = 'A';
let emoji: char = '';
let arabic: char = 'ع';
let chinese: char = '中';
// Note: char uses SINGLE quotes; strings use DOUBLE quotes
println!("{} {} {} {}", letter, emoji, arabic, chinese);
println!("Is letter alphabetic? {}", letter.is_alphabetic());
println!("Is letter uppercase? {}", letter.is_uppercase());
}
Output:
A ع 中
Is letter alphabetic? true
Is letter uppercase? true
Compound Types
Compound types group multiple values. Rust has two primitive compound types: tuples and arrays.
1. Tuples
A tuple groups values of different types into one unit. It has a fixed length.
fn main() {
// A tuple with three different types
let person: (i32, f64, bool) = (30, 5.9, true);
// Access elements by index using dot notation
let age = person.0;
let height = person.1;
let active = person.2;
println!("Age: {}, Height: {}, Active: {}", age, height, active);
// Destructuring: unpack the tuple into separate variables
let (a, b, c) = person;
println!("Destructured: {} {} {}", a, b, c);
// A tuple with no values is called the "unit" type
let _unit: () = ();
}
Output:
Age: 30, Height: 5.9, Active: true
Destructured: 30 5.9 true
2. Arrays
An array holds multiple values of the same type with a fixed length.
fn main() {
// Declare an array of 5 integers
let numbers: [i32; 5] = [1, 2, 3, 4, 5];
// type ^ length
// Access by index (0-based)
println!("First: {}", numbers[0]);
println!("Third: {}", numbers[2]);
println!("Last: {}", numbers[4]);
println!("Length: {}", numbers.len());
// Create an array with all the same value
// This creates [0, 0, 0, 0, 0]
let zeros = [0; 5];
println!("Zeros: {:?}", zeros);
// Iterate over an array
let fruits = ["apple", "banana", "cherry"];
for fruit in fruits {
println!("Fruit: {}", fruit);
}
}
Output:
First: 1
Third: 3
Last: 5
Length: 5
Zeros: [0, 0, 0, 0, 0]
Fruit: apple
Fruit: banana
Fruit: cherry
Note: Arrays in Rust have a fixed size. If you need a resizable collection, use a
Vec<T>(covered in Post 8).
Type Annotations and Type Inference
Rust can infer types in most cases, but sometimes you need to be explicit:
fn main() {
// Type inferred — Rust figures it out
let score = 100; // i32
let temperature = 36.6; // f64
let greeting = "Hello"; // &str
// Type annotated — you tell Rust explicitly
let big_number: i64 = 1_000_000_000_000;
let small_float: f32 = 3.14;
// When parsing a string, you MUST annotate the type
// because Rust doesn't know what numeric type you want
let parsed: i32 = "42".parse().expect("Not a number!");
println!("Parsed: {}", parsed);
println!("{} {} {} {} {}", score, temperature, greeting, big_number, small_float);
}
Putting It All Together
Here's a short program using everything we learned:
fn main() {
// Constants
const GRAVITY: f64 = 9.81; // m/s²
// Variables with various types
let planet = "Earth";
let mut altitude: f64 = 1000.0; // meters above ground
let is_falling = true;
println!("Planet: {}", planet);
println!("Starting altitude: {} meters", altitude);
println!("Is the object falling? {}", is_falling);
// Simulate falling for 2 seconds
// distance = 0.5 * g * t²
let time: f64 = 2.0;
let distance_fallen = 0.5 * GRAVITY * time * time;
// Update altitude (needs to be mutable)
altitude -= distance_fallen;
println!("After {}s, altitude is: {:.2} meters", time, altitude);
// ^^^ formats to 2 decimal places
}
Output:
Planet: Earth
Starting altitude: 1000 meters
Is the object falling? true
After 2s, altitude is: 980.38 meters
Summary
In this post you learned:
- ✅ Variables are immutable by default; use
mutfor mutable variables - ✅
constis always immutable and requires a type annotation - ✅ Shadowing lets you re-use a name and even change its type
- ✅ Scalar types: integers (
i32,u8, etc.), floats (f64), booleans (bool), characters (char) - ✅ Compound types: tuples (mixed types, fixed length) and arrays (same type, fixed length)
- ✅ Rust can infer most types, but sometimes you must annotate explicitly
What's Next?
In Post 3, we'll cover Functions and Control Flow — how to write reusable functions, how if/else works in Rust, and how to use loops (loop, while, for).
Next Post: Functions and Control Flow in Rust →
Part of the Learn Rust from Scratch series on CodeWithNeo


Leave a Reply