Variables and Mutability

Destructuring with let

In Rust, the left-hand side of a let statement is not limited to a single variable identifier—it is a pattern. Destructuring uses a pattern on the left-hand side of let to unpack a compound value (such as a tuple, array, or struct) into separate local variables in a single statement.

Anatomy of a Destructuring let Statement

When the pattern on the left mirrors the structure of the value on the right, Rust matches each piece of the value and binds it to the corresponding variable identifier:

        Pattern                     Compound Value
    +--------------+              +----------------+
let (id, score, tag)      =       (101, 98.5, 'A');
     |     |     |                  |     |    |
     |     |     +------------------+-----+----+---> tag   = 'A'   (char)
     |     +------------------------+-----+--------> score = 98.5  (f64)
     +------------------------------+--------------> id    = 101   (i32)
fn main() {
    let record = (101, 98.5, 'A');

    // Unpack all three elements into distinct bindings
    let (id, score, grade) = record;

    println!("ID: {id}, Score: {score}, Grade: {grade}");
}
ID: 101, Score: 98.5, Grade: A

Destructuring Tuples and Nested Structures

Tuple destructuring works for tuples of any arity and can unpack nested tuples of arbitrary depth in one step:

fn min_and_max(a: i32, b: i32) -> (i32, i32) {
    if a < b { (a, b) } else { (b, a) }
}

fn main() {
    let (smallest, largest) = min_and_max(42, 17);
    println!("min = {smallest}, max = {largest}");

    // Nested tuple destructuring
    let nested = ((10, 20), ("width", 1920));
    let ((x, y), (label, pixels)) = nested;
    println!("Point ({x}, {y}), {label} = {pixels}");
}
min = 17, max = 42
Point (10, 20), width = 1920

Destructuring Arrays

Fixed-size arrays ([T; N]) can be destructured using square brackets [a, b, c] when the pattern matches the array's compile-time length (or uses the rest pattern ..):

fn main() {
    let rgb: [u8; 3] = [255, 128, 0];

    let [red, green, blue] = rgb;
    println!("R={red}, G={green}, B={blue}");
}
R=255, G=128, B=0

Destructuring Structs

Named-field structs, tuple structs, and single-variant enums can also be unpacked directly in a let statement:

struct Dimensions {
    width: u32,
    height: u32,
}

struct Millimeters(u32);

fn main() {
    let window = Dimensions {
        width: 1280,
        height: 720,
    };

    // 1. Shorthand field pattern: creates variables named `width` and `height`
    let Dimensions { width, height } = window;
    println!("Shorthand: {width}x{height}");

    // 2. Renamed field pattern: binds `width` to `w` and `height` to `h`
    let Dimensions {
        width: w,
        height: h,
    } = window;
    println!("Renamed:   {w}x{h}");

    // 3. Tuple struct pattern: binds the inner value to `mm`
    let thickness = Millimeters(15);
    let Millimeters(mm) = thickness;
    println!("Thickness: {mm} mm");
}
Shorthand: 1280x720
Renamed:   1280x720
Thickness: 15 mm

Selective Mutability (mut Inside Patterns)

In Rust, mut is a property of an individual variable binding, not the entire let statement. When destructuring a compound value, you mark only the specific variables that need to be mutable with mut:

fn main() {
    let (mut counter, limit) = (0, 5);

    counter += 1;
    // `counter` is mutable, while `limit` remains strictly immutable!
    println!("counter = {counter}, limit = {limit}");
}
counter = 1, limit = 5

Writing let mut (a, b) = (1, 2); is a syntax error (mut must be placed directly before each identifier: let (mut a, mut b) = (1, 2);).

Ignoring Values with _ and ..

When you only need a subset of the elements or fields from a compound type, use _ to ignore a single position or .. (the rest pattern) to ignore multiple remaining elements or fields:

Pattern Meaning Example
_ Ignores exactly one element or field without binding or moving it let (x, _, z) = (1, 2, 3);
.. (in tuple/array) Skips zero or more elements at the start, middle, or end let [first, .., last] = [1, 2, 3, 4];
.. (in struct) Ignores all unmentioned struct fields let Config { host, .. } = cfg;
struct ServerConfig {
    host: &'static str,
    port: u16,
    max_connections: usize,
    timeout_secs: u64,
}

fn main() {
    let scores = [10, 20, 30, 40, 50];

    // Extract the first and last elements, ignoring everything in between
    let [first, .., last] = scores;
    println!("first = {first}, last = {last}");

    // Extract only `host` and `port`, ignoring the rest of the struct fields
    let cfg = ServerConfig {
        host: "127.0.0.1",
        port: 8080,
        max_connections: 1024,
        timeout_secs: 30,
    };
    let ServerConfig { host, port, .. } = cfg;
    println!("Listening on {host}:{port}");
}
first = 10, last = 50
Listening on 127.0.0.1:8080

Ownership and Partial Moves in Destructuring

Destructuring follows Rust's standard ownership rules for each element:

  • Fields whose types implement Copy (such as i32 or bool) are copied into the new bindings.
  • Fields whose types do not implement Copy (such as String or Vec<T>) are moved into the new bindings unless ignored with _ or .. or borrowed via a reference.

Notice that ignoring a non-Copy field with _ does not move or drop that field early; the original owner retains ownership of the ignored field!

fn main() {
    let pair = (String::from("owned text"), String::from("kept in pair"));

    // Move only the first element (`pair.0`); ignore `pair.1` with `_`
    let (moved_first, _) = pair;

    println!("moved_first = {moved_first}");
    // Because `_` did not move `pair.1`, `pair.1` is still valid to read!
    println!("pair.1      = {}", pair.1);
}
moved_first = owned text
pair.1      = kept in pair

If you want to borrow elements without moving them out of the compound value, destructure a reference (&pair) instead:

fn main() {
    let pair = (String::from("alpha"), String::from("beta"));

    // Destructuring `&pair` makes `a` and `b` have type `&String`
    let (a, b) = &pair;

    println!("Borrowed: {a} and {b}");
    println!("Original pair is still intact: {pair:?}");
}
Borrowed: alpha and beta
Original pair is still intact: ("alpha", "beta")

Common Errors

Tuple or Array Length Mismatch

The number of elements in a tuple or array pattern (when .. is not used) must exactly match the type's length:

// ❌ does not compile
fn main() {
    let triple = (1, 2, 3);
    let (a, b) = triple;
}
error[E0308]: mismatched types
 --> src/main.rs:3:9
  |
3 |     let (a, b) = triple;
  |         ^^^^^^   ------ this expression has type `({integer}, {integer}, {integer})`
  |         |
  |         expected a tuple with 3 elements, found one with 2 elements
  |
  = note: expected tuple `({integer}, {integer}, {integer})`
             found tuple `(_, _)`

error: aborting due to 1 previous error

Refutable Patterns in let (E0005)

A standard let statement requires an irrefutable pattern—a pattern that is guaranteed to match every possible value of the type (such as a tuple, array, or struct). Attempting to destructure a multi-variant enum such as Option<T> or Result<T, E> in a plain let statement fails because the value might be None or Err:

// ❌ does not compile
fn main() {
    let maybe_number = Some(42);
    let Some(n) = maybe_number;
}
error[E0005]: refutable pattern in local binding
 --> src/main.rs:3:9
  |
3 |     let Some(n) = maybe_number;
  |         ^^^^^^^ pattern `None` not covered
  |
  = note: `let` bindings require an "irrefutable pattern", like a `struct` or an `enum` with only one variant
  = note: for more information, visit https://doc.rust-lang.org/book/ch19-02-refutability.html
  = note: the matched value is of type `Option<i32>`
help: you might want to use `let else` to handle the variant that isn't matched
  |
3 |     let Some(n) = maybe_number else { todo!() };
  |                                ++++++++++++++++

error: aborting due to 1 previous error

Use if let Some(n) = maybe_number { ... } or let Some(n) = maybe_number else { return; }; when unpacking fallible enum variants.