Tech Wiki

TOPICSSERIES

[Rust Zero to Production 02] Rust Variables and Types: Why Immutability Is the Default

Assigning x = 6 after let x = 5 does not compile in Rust. That can feel overly strict when the change is trivial. The restriction has a practical payoff: a reader can see which bindings may change because those bindings say mut at their declaration. Everywhere else, reassignment is off the table.

A small Rust 2024 program that models an endpoint check interval makes the distinctions concrete. It separates type inference, explicit annotations, mutation, shadowing, and constants, then goes through rustfmt, Clippy, tests, and a real run.

1. Example Scope

  • Let the compiler infer a type when the initializer and later use are unambiguous.
  • Add an annotation where an operation can produce more than one type.
  • Use mut only when one binding represents state that changes.
  • Use shadowing when a transformation produces a new value that should keep the same name.
  • Use a typed const for a fixed rule shared by the program.

The example uses Rust 1.98.1, Cargo 1.98.1, and the Rust 2024 edition.

2. let Bindings and Static Types

A let statement consists of a pattern, an optional type annotation, and an optional initializer. The common case is concise:

let endpoint = "https://example.com/health";

The string literal gives the compiler enough information, so there is no need to spell out the type of endpoint. Rust remains statically typed. Omitting the annotation means the compiler infers the type during compilation, not that the program chooses a type at runtime.

Inference needs a unique answer. The parse method can produce many types, so this conversion states the intended result:

let configured_interval = "45";
let interval_seconds: u64 = configured_interval
    .parse()
    .expect("interval must be an integer");

The u64 annotation resolves the call and documents the boundary between configuration text and the program's numeric interval. Annotating every local would add noise. An annotation earns its place when it resolves ambiguity or makes a domain boundary easier to read.

3. Benefits of Default Immutability

Bindings introduced with let are immutable unless they include mut. Once a value is bound to a name, code cannot assign another value to that same binding. The benefit is avoiding conflicting assumptions: one part of a program may rely on a value staying fixed while another part changes it. Default immutability moves that class of mistake into compiler feedback.

An immutable value can still be read, passed to a function, and used to calculate another value. Reassignment is the operation ruled out here.

let checks_remaining = 3;
checks_remaining -= 1;

Compiling this snippet separately with Rust 1.98.1 produced E0384:

error[E0384]: cannot assign twice to immutable variable `checks_remaining`
help: consider making this binding mutable

The suggested fix is mechanically correct, but it is worth checking whether the code needs changing state or is really moving through a transformation.

4. mut: State Changes, Type Stays

A countdown is one piece of state whose value changes while its role stays the same. A mutable binding fits it well:

let mut checks_remaining = 3;
while checks_remaining > 0 {
    checks_remaining -= 1;
}

mut permits assignments to this binding. It does not let the binding switch types. The following code fails because interval was inferred as &str from its initializer:

let mut interval = "45";
interval = interval.parse::<u64>().expect("interval must be an integer");

The compiler reported E0308:

error[E0308]: mismatched types
expected `&str`, found `u64`

This conversion does not update a string in place. It produces a number. Shadowing can represent that distinction without inventing a throwaway name.

5. Shadowing Creates a Binding

A second let with the same name shadows the earlier binding. Since it is a new binding, its type may differ:

let interval_seconds = "45";
let interval_seconds: u64 = interval_seconds
    .parse()
    .expect("interval must be an integer");
let interval_seconds = interval_seconds.max(30);

Those lines represent input text, a parsed number, and a number with policy applied. The name remains stable, while each completed stage is immutable.

Shadowing is not automatically clearer. If the old and new values must be compared, names such as raw_interval and normalized_interval preserve useful information. Shadowing works best when one concept passes through a short conversion and only the latest representation matters.

6. const: Fixed Program Rules

An immutable local and a constant both reject reassignment, but they have different declarations and jobs. A const requires an explicit type, cannot use mut, and must have an initializer that can be evaluated as a constant expression. Constants may be declared in local or global scope.

const DEFAULT_INTERVAL_SECONDS: u64 = 30;

This is a policy value known when the program is compiled, rather than a local value discovered during one run. Rust's uppercase-with-underscores convention makes that role visible at each use.

Runtime input does not belong in a const initializer. Read it into a let binding, parse it, and apply the constant policy afterward.

7. Declaration Example

The program below puts the choices together. endpoint uses inference. The parse boundary names its result type. Shadowing stores the normalized interval, and only the countdown is mutable.

const DEFAULT_INTERVAL_SECONDS: u64 = 30;

fn normalized_interval(configured: u64) -> u64 {
    configured.max(DEFAULT_INTERVAL_SECONDS)
}

fn main() {
    let endpoint = "https://example.com/health";
    let configured_interval = "45";
    let interval_seconds: u64 = configured_interval
        .parse()
        .expect("interval must be an integer");
    let interval_seconds = normalized_interval(interval_seconds);

    let mut checks_remaining = 3;
    while checks_remaining > 0 {
        println!("checking {endpoint} every {interval_seconds}s ({checks_remaining} remaining)");
        checks_remaining -= 1;
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn keeps_an_interval_above_the_default() {
        assert_eq!(normalized_interval(45), 45);
    }

    #[test]
    fn raises_a_short_interval_to_the_default() {
        assert_eq!(normalized_interval(10), DEFAULT_INTERVAL_SECONDS);
    }
}

Running it prints:

checking https://example.com/health every 45s (3 remaining)
checking https://example.com/health every 45s (2 remaining)
checking https://example.com/health every 45s (1 remaining)

8. Choosing a Declaration

Situation Declaration Why
The initializer and use make the type obvious let value = ... Avoid redundant annotation
Parsing or an API boundary needs one concrete type let value: Type = ... Resolve ambiguity and record intent
One piece of state changes but keeps the same role let mut value = ... Make possible reassignment visible
A transformation creates the next representation of one concept another let value = ... Create a new binding and allow a type change
A shared rule is known at compile time const NAME: Type = ... Name an always-immutable domain value

A useful test for mut versus shadowing is whether the value is changing as the same state or becoming the output of a completed conversion. A countdown is changing state. Parsing text into a number is a conversion.

9. Declaration Checks

The example lives in an isolated package with no dependencies and edition = "2024". These commands were run in order:

cargo fmt --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo run --quiet

rustfmt and Clippy completed without warnings. The test run reported:

running 2 tests
test tests::keeps_an_interval_above_the_default ... ok
test tests::raises_a_short_interval_to_the_default ... ok

test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out

10. Limits of Default Immutability

Removing every mut is not a performance optimization. A loop counter or reusable buffer may be clearest as changing state, and Rust gives you mut for exactly that case. The useful rule is to make mutation explicit, not to pretend mutation never happens.

Binding immutability also does not mean that every reachable piece of internal state is permanently frozen. Rust has interior-mutability types, covered later in the series. For now, read mut on a local binding as a compact warning that reassignment or mutable borrowing can occur through that name.

The next article builds on the function already used here. It separates statements from expressions and shows how a trailing semicolon changes a block's return value.

Full source code

The complete runnable source for this article is available in the Chapter 02 project on GitHub.

Sources


Leave a Reply

Your email address will not be published. Required fields are marked *

Tech Wiki

Built with WordPress · Learn in public.