# Rust basics

> Write Rust with variables, mutability, shadowing, scalar and compound types, functions, and control flow built from expressions.

Rust roadmap · Foundations · 16 min read · https://levelcode.org/roadmaps/rust/rust-basics

Before ownership and borrowing, you need the everyday syntax: how to declare values, which types exist, how functions are written, and how branching and loops work. Much of it will look familiar if you know Go or TypeScript. Two ideas will not: variables are immutable unless you say otherwise, and almost everything is an **expression** that produces a value. Both shape how idiomatic Rust reads.

## Variables, mutability and shadowing

`let` binds a value to a name. The compiler infers the type, and you can write it explicitly after a colon:

```rust
fn main() {
    let x = 5;          // i32, inferred
    let y: f64 = 2.5;   // explicit type
    let name = "Rust";  // &str, a string slice
    println!("{x} {y} {name}");
}
```

Bindings are **immutable by default**. To change one, declare it `mut`:

```rust
fn main() {
    let mut total = 0;
    total += 10;
    total += 5;
    println!("{total}"); // 15
}
```

Immutability by default is a readability feature: when you see `let`, you know the value won't change below; when you see `let mut`, you know to watch it.

**Shadowing** declares a new variable with the same name. It's common when you transform a value step by step, and the type can change:

```rust
fn main() {
    let input = "  42  ";
    let input = input.trim();            // &str
    let input: u32 = input.parse().unwrap(); // now a u32
    println!("{}", input + 1);           // 43
}
```

Shadowing isn't mutation: each `let` creates a new binding, and the old one is just no longer reachable by that name. It saves you inventing names like `input_str` and `input_num`.

**Constants** use `const`, need a type annotation, are written in `SCREAMING_SNAKE_CASE` and must be computable at compile time:

```rust
const MAX_RETRIES: u32 = 3;
const TIMEOUT_MS: u64 = 30 * 1_000; // underscores make numbers readable
```

## Scalar types

| Kind | Types | Notes |
| --- | --- | --- |
| Signed integers | `i8`, `i16`, `i32`, `i64`, `i128`, `isize` | `i32` is the default for integer literals |
| Unsigned integers | `u8`, `u16`, `u32`, `u64`, `u128`, `usize` | `usize` is pointer-sized; used for indexes and lengths |
| Floats | `f32`, `f64` | `f64` is the default |
| Boolean | `bool` | `true` / `false` |
| Character | `char` | A Unicode scalar value, 4 bytes: `'a'`, `'é'`, `'🦀'` |

Rust **never converts numeric types implicitly**. Adding an `i32` to an `i64` is a compile error. Convert explicitly with `as` (which truncates or wraps silently) or, safer, with `From`/`TryFrom`:

```rust
fn main() {
    let small: i32 = 300;
    let big: i64 = small as i64 + 1;          // widening with as: always fine
    let byte = small as u8;                   // 44: 300 wraps modulo 256, silently
    let checked = u8::try_from(small);        // Err(...): 300 doesn't fit
    let ok: i64 = i64::from(small);           // From is only implemented for lossless conversions
    println!("{big} {byte} {checked:?} {ok}");
}
```

What about overflow? `let x: u8 = 255; x + 1` panics in debug builds and wraps to 0 in release builds. When wrapping or saturating is what you want, say so: `x.wrapping_add(1)`, `x.saturating_add(1)` or `x.checked_add(1)` (which returns `None` on overflow).

> [!TIP]
> Reach for `as` only for widening conversions or when truncation is truly intended. For anything that can lose data, `try_from` makes the failure visible.

## Compound types: tuples and arrays

A **tuple** groups a fixed number of values of possibly different types. Access fields by position or destructure them:

```rust
fn main() {
    let point: (i32, i32, &str) = (3, -1, "A");
    let (x, y, label) = point;      // destructuring
    println!("{label}: {x},{y} / {}", point.0);
}
```

The empty tuple `()` is called **unit**. It's the value of expressions that don't produce anything useful, and the return type of functions that return nothing.

An **array** has a fixed length known at compile time, and every element has the same type. Its type includes the length: `[i32; 5]`.

```rust
fn main() {
    let primes = [2, 3, 5, 7, 11];
    let zeros = [0u8; 1024];       // 1024 zero bytes
    println!("{} {}", primes[0], primes.len());
    println!("{}", zeros.len());

    let i = 10;
    // primes[i] would panic at runtime: index out of bounds: the len is 5 but the index is 10
    println!("{:?}", primes.get(i)); // None: the non-panicking alternative
}
```

Arrays live on the stack and are rarely what you want for collections that grow. That's `Vec<T>`, covered in the [collections lesson](https://levelcode.org/roadmaps/rust/collections-and-iterators). Rust always checks indexes; an out-of-bounds read is a panic, never silent memory corruption.

## Functions

```rust
fn area(width: f64, height: f64) -> f64 {
    width * height
}

fn greet(name: &str) {
    println!("Hello, {name}");
}

fn main() {
    greet("Lin");
    println!("{}", area(3.0, 4.5));
}
```

Parameters must have types; there's no inference across function signatures. That's deliberate: a signature is a contract, and the compiler can check every call against it without reading the body. The return type comes after `->`; leaving it out means the function returns `()`.

Notice that `area` has no `return` and no semicolon on its last line. That's the next section.

## Statements and expressions

In Rust, nearly everything is an **expression**: it evaluates to a value. A **statement** performs an action and produces nothing. Adding a semicolon turns an expression into a statement.

A block `{ ... }` is an expression whose value is its last expression, if that line has no semicolon:

```rust
fn main() {
    let y = {
        let x = 3;
        x * 2 // no semicolon: this is the block's value
    };
    println!("{y}"); // 6
}
```

That's why a function body can end with a bare expression: the body is a block, and its value is the return value. `return` exists for early exits.

```rust
fn classify(n: i32) -> &'static str {
    if n < 0 {
        return "negative"; // early exit
    }
    if n == 0 { "zero" } else { "positive" } // final expression
}

fn main() {
    println!("{} {} {}", classify(-4), classify(0), classify(9));
}
```

A classic first error is adding a semicolon to the last line:

```text
error[E0308]: mismatched types
 --> src/main.rs:1:24
  |
1 | fn area(w: f64, h: f64) -> f64 {
  |    ----                    ^^^ expected `f64`, found `()`
2 |     w * h;
  |          - help: remove this semicolon to return this value
```

With the semicolon, the block's value is `()`, which isn't an `f64`.

## Control flow

`if` is an expression, so it replaces the ternary operator other languages have. The condition must be a `bool`; there's no truthiness, so `if count { }` doesn't compile.

```rust
fn main() {
    let temp = 31;
    let label = if temp > 30 { "hot" } else if temp > 15 { "mild" } else { "cold" };
    println!("{label}");
}
```

Both branches must have the same type.

Rust has three loops. `loop` repeats forever until you `break`, and can return a value from `break`:

```rust
fn main() {
    let mut attempts = 0;
    let result = loop {
        attempts += 1;
        if attempts == 3 {
            break attempts * 10; // the loop's value
        }
    };
    println!("{result}"); // 30
}
```

`while` loops on a condition. `for` iterates over anything iterable, and is what you'll use most:

```rust
fn main() {
    for i in 0..3 { print!("{i} "); }        // 0 1 2   (end-exclusive)
    for i in (1..=3).rev() { print!("{i} "); } // 3 2 1  (end-inclusive, reversed)
    println!();

    let names = ["ana", "bo", "cy"];
    for (i, name) in names.iter().enumerate() {
        println!("{i}: {name}");
    }
}
```

Loops can be labelled to break out of an outer loop from an inner one:

```rust
fn main() {
    'outer: for x in 0..10 {
        for y in 0..10 {
            if x * y == 42 {
                println!("found {x} x {y}");
                break 'outer;
            }
        }
    }
}
```

A `for` over a range compiles to the same machine code as a C-style index loop, and it can't go out of bounds. Prefer it to `while i < len`.

> [!NOTE]
> `match` is the other big control-flow tool: a `switch` that can destructure data and that the compiler checks for exhaustiveness. It's covered with enums in the [structs and enums lesson](https://levelcode.org/roadmaps/rust/structs-and-enums).

## Practice

Write a program that prints the first 20 lines of FizzBuzz, but build each line as a value instead of printing in every branch:

1. Write `fn fizzbuzz(n: u32) -> String` that returns `"FizzBuzz"`, `"Fizz"`, `"Buzz"` or the number as a string. Use `if` as an expression and no `return` keyword.
2. In `main`, loop over `1..=20` and print each result.
3. Then add `fn sum_until(limit: u32) -> u32` that uses `loop` and `break value` to add 1, 2, 3, … until the running sum would exceed `limit`, and returns the last sum that didn't.
4. Bonus: what happens to `sum_until` if you change the types to `u8` and pass `300`? Try it in debug and in release.

**Solution**

```rust
fn fizzbuzz(n: u32) -> String {
    if n % 15 == 0 {
        "FizzBuzz".to_string()
    } else if n % 3 == 0 {
        "Fizz".to_string()
    } else if n % 5 == 0 {
        "Buzz".to_string()
    } else {
        n.to_string()
    }
}

fn sum_until(limit: u32) -> u32 {
    let mut sum = 0;
    let mut i = 1;
    loop {
        if sum + i > limit {
            break sum;
        }
        sum += i;
        i += 1;
    }
}

fn main() {
    for n in 1..=20 {
        println!("{}", fizzbuzz(n));
    }
    println!("{}", sum_until(100)); // 91 = 1 + 2 + ... + 13
}
```

Every branch returns a `String`, so the whole `if` is a `String` expression and becomes the function's return value. String literals are `&str`, so they need `.to_string()` to match the number branch; the [borrowing lesson](https://levelcode.org/roadmaps/rust/borrowing) explains the difference.

Bonus: with `u8`, `limit: u8` can't even hold `300`; the compiler rejects the literal (`literal out of range for u8`). With a limit of `255`, the running sum stays under 255, but `sum + i` reaches 276 when it checks the next number. That panics with "attempt to add with overflow" in a debug build, and silently wraps in release, which returns the wrong answer. Using `sum.checked_add(i)` handles it correctly.

## Key takeaways

- Bindings are immutable by default; use `let mut` to allow change, and shadowing to transform a value under the same name.
- Numeric types never convert implicitly. Use `as` for widening, `try_from` when data could be lost, and `checked_`/`wrapping_`/`saturating_` methods for overflow.
- Blocks, `if`, `match` and `loop` are expressions. A function returns its final expression; a trailing semicolon turns it into `()`.
- Function signatures always spell out types; inside bodies, inference does the work.
- Prefer `for` over ranges and iterators to index loops: same speed, no bounds bugs.

## Quiz

1. What does this print? let x = 5; let x = x * 2; { let x = 0; } println!("{x}");

   A. 0
   B. 5
   C. It doesn't compile: x is immutable
   D. 10

   **Answer: D.** The second let shadows x with 10. The x = 0 inside the inner block is a separate binding that ends with the block, so the outer x is still 10. Shadowing doesn't mutate anything, so no mut is needed.

2. fn double(n: i32) -> i32 { n * 2; } fails to compile. Why?

   A. Functions must use the return keyword
   B. The semicolon makes the body evaluate to (), not i32
   C. n must be declared mut
   D. i32 can't be multiplied by an integer literal

   **Answer: B.** A block's value is its final expression without a semicolon. With the semicolon, n * 2 is a statement and the block's value is unit, which doesn't match the i32 return type.

3. let a: i32 = 10; let b: i64 = 20; What happens with a + b?

   A. It's a compile error; convert one side explicitly
   B. a is promoted to i64 automatically
   C. b is truncated to i32
   D. It compiles but panics at runtime

   **Answer: A.** Rust never converts numeric types implicitly. Write i64::from(a) + b (lossless) or a as i64 + b.

4. What's the value of result? let mut n = 0; let result = loop { n += 4; if n > 10 { break n; } };

   A. 10
   B. 8
   C. 12
   D. The loop never ends

   **Answer: C.** n goes 4, 8, 12. At 12 the condition n > 10 is true, so break n makes the loop evaluate to 12.
