Expressions

Understand Rusts expression-oriented design - what expressions are, how they differ from statements, and how to use if, match, loops, operators, type casts, closures, and precedence rules effectively

Rust is an expression-oriented language. Nearly every piece of code you write produces a value — from a simple arithmetic operation to an entire block of code. This is not a minor design detail. It shapes how you structure functions, handle control flow, and avoid unnecessary mutable state.

Before diving into the many forms expressions take, it helps to pin down the fundamental distinction that makes the whole system work.

Expression Language

Two categories make up most of the code in a Rust program: statements and expressions.

  • Statements perform an action but do not return a value. Variable declarations with let, function definitions (fn), and module declarations are all statements. They end with a semicolon.
  • Expressions evaluate to a value. Literals, variables, arithmetic operations, function calls, blocks, and control flow constructs like if and match are all expressions.

That distinction shows up immediately when you try to nest a let statement inside a value context:

fn main() {
    let x = (let y = 6); // This will not compile
}

The compiler rejects this because let y = 6 is a statement. It produces no value, so it cannot be used on the right-hand side of an assignment.

Statements cannot be nested inside expressions:

This is a common point of confusion when coming from languages where variable declarations produce values. In Rust, a let statement is never an expression and can never appear where a value is expected.

The real power of expressions becomes visible inside blocks. A block is a sequence of statements and an optional final expression, delimited by curly braces. That block itself is an expression. Its value is the value of its last expression — provided that expression does not end with a semicolon.

fn main() {
    let y = {
        let x = 3;
        x + 1   // No semicolon — this value is returned from the block
    };
    println!("The value of y is: {}", y);
}

Running this prints The value of y is: 4. The block { let x = 3; x + 1 } evaluates to 4, and that value is bound to y.

Semicolons suppress values:

Adding a semicolon after the final expression turns it into a statement that yields () (the unit type) instead of the value you intended. This can cause type mismatch errors in surprising places.

A beginner can think of expressions as “things that compute a result you can use,” and statements as “instructions that set something up.” Once you internalize that blocks and control flow constructs are expressions, you start writing code where values flow naturally from one place to another — without temporary mut variables holding intermediate results.

if and match as Expressions

Rust’s if and match constructs are expressions, meaning they evaluate to a value that can be assigned to a variable, passed to a function, or embedded in a larger expression. This eliminates the need for a separate ternary operator and encourages branching logic to feed directly into value bindings.

if as an Expression

An if expression evaluates to the value of whichever branch executes. All branches must produce values of the same type.

let number = 10;
let result = if number < 5 {
    5
} else {
    10
};
println!("The result is: {}", result);

Because if is an expression, there is no ternary ? : syntax in Rust. The ordinary if is already concise enough to fill that role.

Every branch must evaluate to a compatible type:

If one branch returns an i32 and another returns a String, the compiler rejects the code because the whole expression must have a single, known type at compile time.

match as an Expression

match works similarly. Every arm of a match evaluates to a value of the same type, and the whole match block becomes an expression.

enum Duck {
    Huey,
    Dewey,
    Louie,
}
let duck = Duck::Dewey;
let color = match duck {
    Duck::Huey => "Red",
    Duck::Dewey => "Blue",
    Duck::Louie => "Green",
};
println!("The duck's color is: {}", color);

You can combine match with if guards for more complex conditions:

let year = 1981;
let duck = Duck::Huey;
let color = match duck {
    _ if year < 1980 => "random",
    Duck::Huey if year < 1982 => "Pink",
    Duck::Huey => "Red",
    Duck::Dewey => "Blue",
    Duck::Louie => "Green",
};

Expressions replace mutability:

When control flow is an expression, you often bind a value once without needing mut. In languages where if is only a statement, you would declare a mutable variable first, then reassign it inside each branch. Rust’s expression-based if makes the immutable path the natural one.

Beginners sometimes worry that using if and match as expressions creates “clever” code that is hard to read. The reality is the opposite: it keeps the value’s definition and its initialization in one place, rather than splitting them across a mutable declaration and several assignments.

Loops as Expressions

Rust provides three looping constructs, and they all produce a value when they finish. The way they produce a value differs, and understanding this helps you write loops that communicate their result clearly.

loop

A loop runs indefinitely until a break statement interrupts it. The break keyword can carry a value, and that value becomes the result of the entire loop expression.

let mut counter = 0;
let result = loop {
    counter += 1;
    if counter == 10 {
        break counter * 2;
    }
};
println!("Result: {}", result); // 20

If break never appears with a value, the loop evaluates to () (the unit type).

while and for

while and for loops are also expressions, but they always evaluate to (). Even if you use break with a value inside them, the overall loop expression still has type ().

let mut number = 3;
let result = while number != 0 {
    number -= 1;
};
// result has type ()

break with a value in while and for:

You can write break some_value inside a while or for, but the loop expression ignores that value and still yields (). If you need the loop to produce a useful value, use loop instead.

The primary purpose of while and for is control flow, not producing a value. Use loop when the result of the loop matters.

Function and Method Calls

Calling a function or method is an expression that evaluates to the return value of that call. The return value is either the final expression in the function body (without a trailing semicolon) or the value provided to a return statement.

fn add(a: i32, b: i32) -> i32 {
    a + b   // implicit return
}
fn main() {
    let sum = add(5, 3);
    println!("Sum: {}", sum); // 8
}

An explicit return also works and is itself an expression. return immediately exits the enclosing function with the given value.

Since function calls are expressions, you can nest them, pass them as arguments, and chain method calls:

let length = "hello".to_uppercase().len();

A function that does not declare a return type implicitly returns (). The unit value () is an expression too — it represents the absence of meaningful data.

Functions that return nothing:

A function like fn greet() { println!("Hi"); } has a return type of () because the final expression println!("Hi") evaluates to (). You can use that fact to place such calls in expression positions, though it is rarely useful.

Fields and Elements

Accessing a field of a struct, an element of a tuple, or an element of an array or slice are all expressions that produce the value at that location. These are place expressions: they refer to a location in memory, which means they can appear on the left side of an assignment as well as on the right.

Struct Field Access

The dot operator retrieves the value of a named field.

struct Point {
    x: f64,
    y: f64,
}
let p = Point { x: 2.0, y: 3.5 };
let x_coord = p.x;   // expression yields 2.0

Tuple Element Access

Tuples use a numeric syntax with a dot, not brackets.

let tuple = (10, "hello", true);
let second = tuple.1;   // "hello"

Indexing Arrays, Vectors, and Slices

The square bracket syntax [] accesses an element at a given index. If the index is out of bounds, the program panics at runtime.

let arr = [1, 2, 3, 4, 5];
let third = arr[2];   // 3

Out-of-bounds indexing panics:

Unlike some languages that return a default value, arr[index] causes an immediate panic if index >= arr.len(). For safe access that returns an Option, use the .get() method: arr.get(10) yields None.

The indexing expression can also appear on the left side of an assignment to modify an element:

let mut v = vec![10, 20, 30];
v[1] = 25;

Reference Operators

The reference operators & and * create and follow references, respectively. Both are expressions.

The Reference Operator &

&expr creates a shared reference (&T) to the value produced by expr. &mut expr creates an exclusive (mutable) reference (&mut T).

let x = 10;
let r = &x;       // r: &i32
let m = &mut 5;   // temporary mutable reference to a literal

A reference is itself a value — a pointer with compile-time guarantees about aliasing and lifetimes. You can chain references: &&x yields a reference to a reference.

The Dereference Operator *

*expr follows a reference to access the value it points to. For types that implement the Deref trait, * can also “unwrap” through layers of indirection transparently.

let x = 42;
let r = &x;
assert_eq!(*r, 42);

When the expression on the left side of an assignment is a dereference, the right-hand value is written through the pointer to the original location.

let mut y = 7;
let r = &mut y;
*r += 1;
assert_eq!(y, 8);

Dereferencing moves the value:

If the type behind the reference does not implement Copy, using * on a shared reference will try to move the value out, which is disallowed. You must either borrow the field you need (e.g., &r.field) or use clone().

Arithmetic, Bitwise, Comparison, and Logical Operators

Operators are expressions that evaluate to a new value from their operands. Rust provides the standard set of operators, with a few behaviors that are worth noting explicitly.

Arithmetic Operators

+, -, *, /, % (remainder). The compiler requires both operands to have the same numeric type. Integer division truncates toward zero.

let sum = 5 + 3;        // 8
let remainder = 10 % 3; // 1

Integer overflow panics in debug mode:

By default, arithmetic operations on integers panic on overflow when compiled in debug mode. In release mode, they wrap using two’s complement. Use wrapping_add, checked_add, or overflowing_add methods for explicit overflow behavior.

Bitwise Operators

& (and), | (or), ^ (xor), << (left shift), >> (right shift). These work on integer types.

let a = 0b1100u8;
let b = 0b1010u8;
let bitwise_and = a & b; // 0b1000

Comparison Operators

==, !=, <, >, <=, >=. They produce a bool. The == and != operators require that both sides have the same type.

let equal = (2 + 2) == 4; // true

Logical Operators

&& (logical AND), || (logical OR), ! (logical NOT). These short-circuit: the right-hand operand is evaluated only if the left-hand operand does not determine the result.

let condition = false && expensive_check(); // expensive_check never called

All of these operators can be combined in larger expressions. Readability improves dramatically when parentheses make the evaluation order explicit.

Assignment

Assignment in Rust (=) is an expression that evaluates to the unit value (). This is a deliberate departure from C-like languages where assignment returns the assigned value.

let mut x = 10;
let y = (x = 20);
// y has type () and value ()
assert_eq!(x, 20);

Because assignment yields (), you cannot chain assignments the way you might in C:

let mut a = 1;
let mut b = 2;
a = b = 3; // Error: mismatched types
// expected integer, found `()`

Why assign returns ():

Returning () prevents accidental use of assignment inside condition expressions, a notorious source of bugs where = is typed instead of ==. In Rust, if x = 5 { ... } is a compile error because the expression inside if must be bool, not ().

The compound assignment operators (+=, -=, *=, etc.) also evaluate to ().

Type Casts

The as keyword performs an explicit type conversion. It is an expression that evaluates to the converted value.

let integer: u32 = 42;
let byte: u8 = integer as u8;  // 42 fits in u8, no loss

Numeric casts between integer types truncate the high bits if the source value is larger than the target type can hold. Signed-to-unsigned casts re-interpret the bit pattern, and floating-point-to-integer casts round toward zero.

let big: u32 = 300;
let small: u8 = big as u8; // 300 % 256 = 44

as can silently lose information:

Unlike From/Into conversions, as does not signal truncation or precision loss at compile time or runtime. When exact conversion is important, prefer From or TryFrom traits.

as can also cast raw pointers to and from integer types and convert between pointer types in unsafe code. For safe pointer casts, use the dedicated methods on pointer types.

Closures (Basic Introduction)

A closure is an anonymous function that can capture variables from its surrounding scope. Closure creation is an expression that produces a value whose type implements one of the Fn, FnMut, or FnOnce traits.

let multiplier = 3;
let multiply = |x: i32| x * multiplier;
let result = multiply(5); // 15

The closure |x: i32| x * multiplier captures multiplier by reference. The expression itself evaluates to a closure value that can be stored in a variable and called later.

Closures are expressions, so you can pass them directly to functions:

let numbers = vec![1, 2, 3, 4];
let doubled: Vec<i32> = numbers.iter().map(|n| n * 2).collect();

Closure types are anonymous:

Every closure has a unique, compiler-generated type that implements the appropriate function trait. You cannot write down the type of a closure explicitly; you must use impl Fn() or trait bounds.

Closures play a central role in Rust’s iterator adapters, concurrency primitives, and error handling combinators. A full treatment of closures, including capture modes and the move keyword, appears in Chapter 9.

Precedence and Associativity

When multiple operators appear in a single expression without parentheses, the compiler applies precedence and associativity rules to determine the order of evaluation. Higher-precedence operators bind more tightly.

The full precedence table, from highest to lowest:

PrecedenceOperatorsAssociativity
1Paths, method calls, field expressions, function calls, indexingLeft-to-right
2Unary -, *, !, &, &mutRight-to-left
3asLeft-to-right
4*, /, %Left-to-right
5+, -Left-to-right
6<<, >>Left-to-right
7& (bitwise)Left-to-right
8^Left-to-right
9`` (bitwise)
10==, !=, <, >, <=, >=Require parentheses
11&&Left-to-right
12`
13.., ..=Require parentheses
14=, +=, -=, *=, etc.Right-to-left

Comparison operators (row 10) and range operators (row 13) cannot be chained without parentheses — a deliberate design choice to prevent ambiguous expressions like a == b == c.

Use parentheses to make grouping explicit even when the rules would produce the same result. Code is read far more often than it is written.

// Without parentheses — relies on precedence knowledge
let result = 2 + 3 * 4; // 14, because * binds tighter than +
// With parentheses — intent is explicit
let result = 2 + (3 * 4);

When in doubt, parenthesize:

There is no runtime cost to parentheses. They clarify intent and prevent bugs. If you cannot recall the full precedence table from memory, neither can the next person reading your code.


Expressions are not a single feature of Rust. They are the language’s connective tissue — the mechanism that turns individual operations into a pipeline of values. Once you stop reaching for mutable temporaries and start letting if, match, blocks, and closures produce your values directly, the code becomes shorter and its intent more transparent.