Can you write this from memory?
Declare an immutable variable `x` with the value `5`.
You're typing a Rust variable declaration and you pause: is it let mut x or mut let x? You reach for a constant and blank on whether const needs a type annotation. You shadow a variable and the compiler yells about mismatched types.
These moments happen because Rust's foundations have more moving parts than most languages. Variables are immutable by default. Types can be inferred or explicit. Shadowing lets you reuse names while changing types. Small details, but they trip you up every time you haven't written Rust in a week.
This page focuses on the foundational syntax until let, mut, const, and type annotations are automatic.
| Keyword | Mutable? | Type annotation | Evaluated at | Scope |
|---|---|---|---|---|
let x = 5 | No | Optional (inferred) | Runtime | Block |
let mut x = 5 | Yes | Optional (inferred) | Runtime | Block |
const X: u32 = 5 | Never | Required | Compile time | Any |
static X: u32 = 5 | No (or static mut) | Required | Compile time | Global ('static) |
let x = x + 1 (shadow) | Creates new variable | Optional | Runtime | Block |
const values are inlined by the compiler and don't have a guaranteed memory address. static values live at a fixed location for the entire program.
If you're coming from Python, Rust's variable rules work quite differently. See how Python and Rust handle variables for a side-by-side comparison.
Variables are immutable by default. Add mut when you need to reassign:
// WRONG: let binding is immutable
// let count = 0;
// count += 1; // error[E0384]
// RIGHT: declare with mut
let mut count = 0;
count += 1;
count += 1;
println!("{count}"); // 2
Shadowing creates an entirely new variable that reuses the name. Unlike mut, shadowing can change the type:
// WRONG: mut cannot change the type
// let mut x = "hello";
// x = x.len(); // error[E0308]: expected &str, found usize
// RIGHT: shadow to change type
let x = "hello";
let x = x.len(); // OK: new variable, type is now usize
// Shadowing chain — each let creates a new variable
let x = 5;
let x = x + 1; // 6
let x = x * 2; // 12
In Rust, the last expression in a block (without a semicolon) is its return value. Adding ; turns it into a statement that returns ():
fn add(a: i32, b: i32) -> i32 {
a + b // no semicolon → returns i32
}
// WRONG: semicolon makes it return ()
// fn add(a: i32, b: i32) -> i32 {
// a + b; // returns () — error[E0308]: expected i32, found ()
// }
This applies everywhere blocks return values — functions, if expressions, match arms, closures.
if is an expression — both branches must return the same type:
// WRONG: branches return different types
// let status = if ready { "ok" } else { 42 }; // error[E0308]
// RIGHT: same type in both branches
let status = if ready { "ok" } else { "wait" };
This also catches the semicolon mistake (function declares -> i32 but returns ()) and type mismatches in match arms. For a deeper look at how match interacts with Rust's type system, see the pattern matching guide.
Scalar Types
| Type | Examples | Notes |
|---|---|---|
| Integer | i8, i16, i32, i64, i128, isize | Signed. Default: i32 |
| Unsigned | u8, u16, u32, u64, u128, usize | usize for indexing |
| Float | f32, f64 | Default: f64 |
| Boolean | bool | true / false |
| Character | char | 4 bytes, Unicode scalar value |
Numeric literals support type suffixes and visual separators:
let x = 42; // i32 (default)
let y = 42u64; // u64 (suffix)
let z = 1_000_000; // underscores for readability
let hex = 0xff; // hexadecimal
let bin = 0b1010; // binary
let byte = b'A'; // u8
Compound Types
// Tuple: fixed-size, mixed types
let tup: (i32, f64, u8) = (500, 6.4, 1);
let (x, y, z) = tup; // destructuring
let first = tup.0; // index access
// Array: fixed-size, same type
let arr: [i32; 5] = [1, 2, 3, 4, 5];
let zeros = [0; 10]; // [0, 0, 0, ..., 0] (10 elements)
let first = arr[0]; // index access (panics on out-of-bounds)
Most common with parse(), because the compiler cannot guess the target type:
// WRONG: what type should "42" parse into?
// let n = "42".parse().unwrap(); // error[E0282]
// RIGHT: annotate the binding
let n: i32 = "42".parse().unwrap();
// RIGHT: turbofish on the method
let n = "42".parse::<i32>().unwrap();
Also required for const and static declarations (type is never inferred):
const MAX: u32 = 100; // type required
static GREETING: &str = "hi"; // type required
const is evaluated at compile time. It must have a type annotation, uses SCREAMING_SNAKE_CASE, and can never be mut:
const MAX_POINTS: u32 = 100_000;
const PI: f64 = 3.14159;
const GREETING: &str = "hello";
Use const for values fixed across the entire program. Use let for everything else — let bindings are computed at runtime and can depend on function arguments or other variables. Understanding how let and ownership interact is essential; the ownership and borrowing guide covers what happens when you assign or pass values.
Every parameter needs a type annotation. The return type goes after ->:
fn add(a: i32, b: i32) -> i32 {
a + b // no semicolon = return value
}
fn greet(name: &str) -> String {
format!("Hello, {name}!")
}
if, match, and loop are all expressions — they return values:
let status = if is_ready { "ok" } else { "wait" };
let description = match code {
200 => "ok",
404 => "not found",
_ => "unknown",
};
Loops use iterators for for:
for n in [10, 20, 30] {
println!("{n}");
}
for i in 0..5 {
println!("{i}"); // 0, 1, 2, 3, 4
}
let mut count = 0;
while count < 3 {
count += 1;
}
Once you move beyond scalar types, structs and enums let you define custom compound types that the compiler checks just as rigorously.
Unused Variables: the _ Prefix
Rust warns about unused variables. Prefix with _ to silence the warning:
let _unused = compute_something(); // no warning
let _ = risky_op(); // discard result (no binding at all)
- The Rust Book: Common Programming Concepts — variables, types, functions, control flow
- The Rust Book: Data Types — scalars, tuples, arrays
- Rust Reference: Items and Expressions — const, static, expression semantics
When to Use Rust Foundations: Variables, Types & Basic Syntax
- Use `let` for most variables. Immutability prevents accidental changes.
- Use `let mut` only when you genuinely need to modify the value.
- Use `const` for compile-time constants that never change.
- Use type inference when the type is obvious; annotate when it helps readability.
- Use shadowing to transform a value while keeping the same name.
Check Your Understanding: Rust Foundations: Variables, Types & Basic Syntax
What is the difference between shadowing and mutation in Rust?
Shadowing creates a new variable with the same name (can even change types). Mutation modifies the existing variable in place (requires `mut`). Shadowing uses `let` again; mutation does not.
What You'll Practice: Rust Foundations: Variables, Types & Basic Syntax
Common Rust Foundations: Variables, Types & Basic Syntax Pitfalls
- You see `cannot assign twice to immutable variable` -- you tried to modify a `let` binding. Add `mut`: `let mut x = 5;`.
- You shadow a variable and the old value disappears -- shadowing creates a new variable, it does not modify the original. If you need the old value, use a different name.
- You write `const MAX = 100;` and get `expected type annotation` -- `const` always requires an explicit type. Write `const MAX: u32 = 100;`.
- Your function returns `()` instead of a value -- you put a semicolon on the last expression. Remove the semicolon: `a + b` not `a + b;`.
- You get `mismatched types` in an `if` expression -- both branches must return the same type. Check that `if` and `else` produce matching types.
- You try `let mut x = "hello"; x = x.len();` and get a type error -- `mut` allows changing the value, not the type. Use shadowing with a new `let` instead.
Rust Foundations: Variables, Types & Basic Syntax FAQ
Why are variables immutable by default?
Immutability makes code easier to reason about and enables compiler optimizations. Rust's philosophy is that mutation should be explicit and intentional.
When should I use type annotations?
When the compiler can't infer the type (like with `parse()`), when it improves readability, or when you want to be explicit about the exact type (e.g., `i32` vs `i64`).
What's the difference between const and static?
`const` defines a constant value that is inlined wherever it is used — references to it are not guaranteed to point to the same address. `static` defines a single allocated location that lives for the entire program. Use `const` for compile-time constants, `static` when you need a fixed address or global state.
Can I shadow a variable with a different type?
Yes! Shadowing creates an entirely new variable, so `let x = "5"; let x = x.parse::<i32>().unwrap();` is valid. This is useful for type transformations.
Why use underscores in numbers like 100_000?
Underscores are visual separators that make large numbers readable. The compiler ignores them: `1_000_000` is the same as `1000000`.
Rust Foundations: Variables, Types & Basic Syntax Syntax Quick Reference
let x = 5;let mut count = 0;
count += 1;let x: i32 = 5;const MAX_POINTS: u32 = 100_000;let x = 5;
let x = x + 1;
let x = x * 2;let spaces = " ";
let spaces = spaces.len();let x = 5i64;
let y = 3.14f32;let tup: (i32, f64, u8) = (500, 6.4, 1);let arr: [i32; 5] = [1, 2, 3, 4, 5];fn add(a: i32, b: i32) -> i32 {
a + b
}let is_ready = true;
let status = if is_ready { "ok" } else { "wait" };Rust Foundations: Variables, Types & Basic Syntax Sample Exercises
Write a minimal Rust program that prints "Hello, world!".
fn main() {
println!("Hello, world!");
}
Fill in the Rust syntax for a line comment.
//Add a type annotation to declare x as a 32-bit signed integer.
: i32+ 54 more exercises