Rust 8 🦀 Statements vs Expressions in Rust Syntax
The distinction between statements and expressions is the single most important syntactic concept in Rust. It explains why function bodies work the way they do, why if and match can produce values, why a stray semicolon changes a return type, and why let cannot appear on the right-hand side of an assignment. Every Rust programmer encounters this distinction early, usually through a confusing compiler error that says “expected i32, found ()” after adding a semicolon to the last line of a function. Understanding it deeply removes that confusion and unlocks the language’s expression-oriented style.
The rule is simple to state: an expression produces a value; a statement performs an action and produces no value. A Rust program is a sequence of statements, and many statements contain expressions. A block is itself an expression, and its value is the value of its final expression if that expression has no trailing semicolon. This is why function bodies return values without a return keyword, and it is why the placement of a semicolon can change the meaning of a line.
This chapter covers the definition of statements and expressions, the role of the semicolon, block expressions, the item statements, expression statements, control flow as expressions, and the patterns that follow from the expression-oriented design.
Key point: Expressions produce values; statements do not. A block’s value is its final expression without a semicolon. Adding a semicolon turns the expression into a statement whose value is (). This single rule explains function returns, if/match values, and the most common Rust syntax errors.
Why the distinction matters
The return-value problem. A function must return a value of its declared type. In Rust, the function body is a block, and the block’s value is the return value. There is no separate return statement required for the common case. This design means the last line of a function is an expression, not a statement, and the difference between them is the semicolon.
The control-flow-value problem. In many languages, if is a statement and cannot be assigned. In Rust, if is an expression, so let x = if cond { 1 } else { 2 }; is valid. The same applies to match and blocks. This uniformity means conditional logic can appear anywhere a value is expected, without a temporary variable or a helper function.
The statement-context problem. Some positions require a statement, not an expression. A let binding is a statement; the expression on its right produces the value, but the binding itself does not. An expression followed by a semicolon becomes a statement. Understanding which positions expect statements and which expect expressions is what makes Rust’s grammar predictable.
The error-message problem. When the compiler reports “expected i32, found ()“, it is usually because a semicolon turned an expression into a statement. Knowing this rule lets you read the error and fix it immediately rather than staring at the code.
The design-uniformity problem. Rust’s expression orientation is not accidental. It makes the language composable: any expression can be nested in any other expression, and control flow constructs are expressions that fit into that model. This uniformity is what allows match to be used as a function body, as an argument, and as a binding initializer without special cases.
a. Statements
A statement is an instruction that performs an action and does not produce a value. Rust has two kinds of statements: declaration statements and expression statements.
Declaration statements introduce a name or an item. The let binding is the most common:
let x = 5; // declaration statement
let mut y = 10; // declaration statement
Item declarations — fn, struct, enum, impl, use, mod — are also statements when they appear inside a block:
fn main() {
fn helper() -> i32 { 42 } // item declaration as a statement
struct Point { x: i32, y: i32 }
use std::collections::HashMap;
// ...
}
Item declarations do not produce values. They introduce names that the rest of the block can use.
Expression statements are expressions followed by a semicolon. The semicolon discards the value and turns the expression into a statement:
5 + 3; // expression statement: computes 8, discards it
println!("hello"); // expression statement: prints, discards ()
foo(); // expression statement: calls foo, discards its value
The value of an expression statement is (). This is what makes the semicolon so consequential: it changes the type of the line from whatever the expression produced to ().
b. Expressions
An expression evaluates to a value. Almost everything in Rust is an expression: literals, variables, operators, function calls, blocks, if, match, loops, and more.
5 // literal expression, value 5
x // variable expression, value of x
x + 1 // arithmetic expression, value x + 1
foo() // call expression, value returned by foo
{ let y = 3; y + 1 } // block expression, value 4
if x > 0 { 1 } else { 0 } // if expression, value 1 or 0
match x { 1 => "one", _ => "other" } // match expression
Expressions can appear in any position that expects a value:
let a = 5 + 3; // right side of let
let b = if a > 0 { 1 } else { -1 }; // if expression
let c = { let d = 2; d * 3 }; // block expression
let e = match a { 8 => "eight", _ => "other" };
They can also be nested inside each other:
let result = if match x { 1 => true, _ => false } { 10 } else { 20 };
This nesting is what makes Rust’s expression model powerful. A match can be the condition of an if, and an if can be the value of a block.
c. The semicolon: expression to statement
The semicolon is the operator that turns an expression into an expression statement. The value of the expression is discarded, and the statement’s value becomes ().
let a = 5; // statement
let b = a + 1; // statement
{
a + 1; // expression statement; value is discarded
// the block's value is () because the expression
// was converted to a statement
}
In a function body, the final line determines the return value:
fn no_semicolon() -> i32 {
5 // expression: block value is 5, function returns 5
}
fn with_semicolon() -> i32 {
5; // statement: block value is (), function returns ()
// error: expected i32, found ()
}
The compiler error for the second function is:
error[E0308]: mismatched types
--> src/main.rs:2:5
|
2 | 5;
| ^^ expected `i32`, found `()`
|
= note: expected type `i32`
found type `()`
This is the most common error newcomers encounter. The fix is to remove the semicolon.
A block can mix statements and a final expression:
fn compute() -> i32 {
let a = 5; // statement
let b = 10; // statement
a + b // expression: block value is 15
}
Only the last line is the block’s value. Statements before it are evaluated for their side effects and then discarded.
d. Blocks as expressions
A block is a sequence of statements followed by an optional final expression, wrapped in braces. It is an expression, and its value is the value of the final expression if present, or () if not.
let x = {
let a = 3;
let b = 4;
a + b // block value
};
println!("{}", x); // 7
A block without a final expression has the value ():
let y = {
println!("side effect");
// no final expression
};
// y is ()
Block expressions are useful for scoping. Variables declared inside a block are not visible outside it:
let x = 5;
let y = {
let x = 10; // shadows outer x
x * 2 // 20
};
println!("{} {}", x, y); // 5 20
The inner x shadows the outer one within the block, but the outer x is unchanged. This is a common pattern for temporary computations.
e. Control flow as expressions
In Rust, if, match, and loop are expressions. Each produces a value.
if expressions return the value of the branch that executes:
let max = if a > b { a } else { b };
Both branches must produce the same type:
// Error: mismatched types
let x = if cond { 5 } else { "hello" };
An if without an else has type () when used as an expression, because the missing branch would produce ():
// This works: if without else is a statement
if cond {
println!("yes");
}
// This fails: if without else has type ()
let x = if cond { 5 }; // ❌ expected i32, found ()
match expressions return the value of the matched arm:
let description = match n {
0 => "zero",
1 => "one",
_ => "many",
};
All arms must produce the same type. The match expression is exhaustive, and the compiler checks that every case is covered.
loop expressions can return a value with break:
let result = loop {
let input = read_input();
if input == "quit" {
break 0;
}
if let Ok(n) = input.parse::<i32>() {
break n;
}
};
The loop expression runs until a break with a value exits it, and the value of the break becomes the value of the loop expression.
while and for loops are expressions with type (). They do not produce a value; they execute for their side effects.
let x = while cond { ... }; // x is ()
let y = for i in 0..10 { }; // y is ()
These are rarely used as expressions; they are normally written as statements.
f. Statements, expressions, and let
The let binding is a statement, not an expression. This means it cannot appear on the right-hand side of an assignment or inside an expression.
// Error: let is a statement
let x = (let y = 5);
The pattern on the left of let is destructuring, and the expression on the right produces the value:
let (a, b) = (1, 2); // destructuring in let
let Point { x, y } = point; // struct destructuring
let [first, ..] = array; // slice pattern
The right side must be an expression; the left side is a pattern. The binding itself does not produce a value.
A let with a block expression on the right is common:
let result = {
let a = compute_a();
let b = compute_b();
a + b
};
The block produces the value, and let binds it.
Complete Example Session
// ============================================
// PART 1: EXPRESSION STATEMENTS
// ============================================
fn main() {
5 + 3; // expression statement, value discarded
println!("hello"); // expression statement
42; // expression statement, value discarded
}
// ============================================
// PART 2: LET AS A STATEMENT
// ============================================
fn main() {
let x = 5; // declaration statement
let y = x + 1; // declaration statement
println!("{}", y); // 6
}
// ============================================
// PART 3: SEMICOLON CHANGES RETURN TYPE
// ============================================
fn returns_five() -> i32 {
5 // expression: returns 5
}
fn returns_unit() -> i32 {
5; // statement: returns ()
// error: expected i32, found ()
}
fn main() {
println!("{}", returns_five());
}
// ============================================
// PART 4: BLOCK AS EXPRESSION
// ============================================
fn main() {
let x = {
let a = 3;
let b = 4;
a + b // block value is 7
};
println!("{}", x); // 7
}
// ============================================
// PART 5: IF AS EXPRESSION
// ============================================
fn main() {
let a = 10;
let b = 20;
let max = if a > b { a } else { b };
println!("{}", max); // 20
}
// ============================================
// PART 6: MATCH AS EXPRESSION
// ============================================
fn main() {
let n = 2;
let description = match n {
0 => "zero",
1 => "one",
2 => "two",
_ => "many",
};
println!("{}", description); // two
}
// ============================================
// PART 7: LOOP AS EXPRESSION WITH BREAK
// ============================================
fn main() {
let mut counter = 0;
let result = loop {
counter += 1;
if counter == 5 {
break counter * 2;
}
};
println!("{}", result); // 10
}
// ============================================
// PART 8: WHILE AND FOR ARE ()
// ============================================
fn main() {
let x: () = while false { }; // x is ()
let y: () = for _ in 0..0 { }; // y is ()
println!("{:?} {:?}", x, y); // () ()
}
// ============================================
// PART 9: LET IS A STATEMENT
// ============================================
fn main() {
// This would fail: let x = (let y = 5);
let x = {
let y = 5;
y + 1
};
println!("{}", x); // 6
}
// ============================================
// PART 10: MIXING STATEMENTS AND EXPRESSION
// ============================================
fn compute() -> i32 {
let a = 10; // statement
let b = 20; // statement
if a > b { // expression
a
} else {
b
}
}
fn main() {
println!("{}", compute()); // 20
}
These ten parts cover expression statements, let as a statement, the semicolon’s effect on return type, block expressions, if/match/loop as expressions, the unit type of while/for, and mixing statements with a final expression.
Quick Reference
Statements vs Expressions
| Kind | Produces Value | Examples |
|---|---|---|
| Expression | Yes | 5, x + 1, foo(), { ... }, if, match, loop |
| Declaration statement | No | let x = 5;, fn f() {} |
| Expression statement | No (()) | 5 + 3;, foo();, println!("hi"); |
The Semicolon Rule
| Form | Meaning |
|---|---|
5 | Expression, value 5 |
5; | Statement, value () |
{ 5 } | Block expression, value 5 |
{ 5; } | Block expression, value () |
Control Flow as Expression
| Construct | Produces Value | Notes |
|---|---|---|
if with both branches | Yes | Types must match |
if without else | () | Statement form |
match | Yes | All arms same type |
loop with break value | Yes | Value of break |
while | () | Side effects only |
for | () | Side effects only |
Where Statements Are Required
| Position | Expects |
|---|---|
| Function body | Statements then optional final expression |
| Block | Same |
let right side | Expression only |
| Function argument | Expression only |
if condition | Expression only |
Best Practices
✅ Do This:
fn add(a: i32, b: i32) -> i32 {
a + b // expression: no semicolon
}
let max = if a > b { a } else { b }; // if expression
let x = {
let a = 3;
a * 2 // block expression
};
let result = loop {
if done { break 42; }
};
❌ Don’t Do This:
fn add(a: i32, b: i32) -> i32 {
a + b; // ❌ statement: returns ()
}
let x = if cond { 5 }; // ❌ no else: type is ()
let x = (let y = 5); // ❌ let is a statement
5 + 3; // ❌ discarded value (warning)
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
expected i32, found () | Semicolon on final expression | Remove semicolon |
if without else in expression | Missing branch has type () | Add else or use statement form |
match arms different types | Arms must produce same type | Make arms consistent |
let in expression position | let is a statement | Use a block or restructure |
| Unused value warning | Expression statement discards value | Remove semicolon or use value |
Block returns () unexpectedly | Missing final expression | Add final expression or return |
Real-World Examples
1. Function Return
fn square(x: i32) -> i32 {
x * x
}
2. If Expression
let label = if count > 0 { "has items" } else { "empty" };
3. Match Expression
let size = match n {
0 => "none",
1..=5 => "small",
_ => "large",
};
4. Block Scoping
let total = {
let subtotal = 100;
let tax = subtotal * 0.1;
subtotal + tax
};
5. Loop with Break Value
let first_even = loop {
let n = next();
if n % 2 == 0 { break n; }
};
6. Early Return
fn find(items: &[i32], target: i32) -> Option<usize> {
for (i, &v) in items.iter().enumerate() {
if v == target {
return Some(i);
}
}
None
}
7. Nested Block
let x = {
let y = {
let z = 5;
z * 2
};
y + 1
};
8. Match in Function Body
fn describe(n: i32) -> &'static str {
match n {
0 => "zero",
n if n > 0 => "positive",
_ => "negative",
}
}
9. If in Argument
println!("{}", if active { "on" } else { "off" });
10. Block in Let
let config = {
let mut c = Config::default();
c.timeout = 30;
c
};
Visual
Statements and Expressions
┌──────────────────────────────────────────────────────────────┐
│ EXPRESSION STATEMENT │
│ ───────────── ───────── │
│ Produces a value Performs an action │
│ Can be nested Cannot be nested in value │
│ │
│ 5 let x = 5; │
│ x + 1 5 + 3; │
│ foo() foo(); │
│ { let a = 1; a } { let a = 1; a; } │
│ if c { 1 } else { 2 } fn f() {} │
│ match x { ... } │
└──────────────────────────────────────────────────────────────┘
The Semicolon Rule
┌──────────────────────────────────────────────────────────────┐
│ 5 → expression, value 5 │
│ 5; → statement, value () │
│ │
│ fn f() -> i32 { │
│ 5 ← returns 5 ✅ │
│ } │
│ │
│ fn f() -> i32 { │
│ 5; ← returns () ❌ type mismatch │
│ } │
└──────────────────────────────────────────────────────────────┘
Block as Expression
┌──────────────────────────────────────────────────────────────┐
│ let x = { │
│ let a = 3; ← statement │
│ let b = 4; ← statement │
│ a + b ← final expression: block value is 7 │
│ }; │
│ │
│ x = 7 │
│ │
│ let y = { │
│ let a = 3; │
│ a + 1; ← statement: block value is () │
│ }; │
│ │
│ y = () │
└──────────────────────────────────────────────────────────────┘
Control Flow as Expressions
┌──────────────────────────────────────────────────────────────┐
│ if c { 1 } else { 2 } → value 1 or 2 │
│ match x { 1 => "a", _ => "b" } → "a" or "b" │
│ loop { break 42; } → value 42 │
│ while c { } → value () │
│ for i in 0..n { } → value () │
│ │
│ All of these can appear on the right side of let: │
│ let x = if c { 1 } else { 2 }; │
│ let y = match n { 1 => "a", _ => "b" }; │
│ let z = loop { break 42; }; │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Expression | Produces a value |
| Statement | Performs an action, value is () |
| Declaration statement | let, fn, struct, use |
| Expression statement | Expression followed by ; |
| Semicolon effect | Discards value, makes statement |
| Block value | Final expression if no semicolon, else () |
if expression | Value of taken branch; both branches same type |
match expression | Value of matched arm; all arms same type |
loop expression | Value from break value |
while/for | Type () |
let position | Statement, not expression |
Key takeaways:
- Expressions produce values; statements do not. This distinction determines what can appear where and why the semicolon matters.
- The semicolon turns an expression into a statement. The value of the expression is discarded, and the statement’s value becomes
(). - A block’s value is its final expression without a semicolon. This is why function bodies return values without
return, and why a stray semicolon produces the “expectedT, found()” error. if,match, andloopare expressions. They produce values and can appear on the right side oflet, as function arguments, and nested inside other expressions.whileandforhave type(). They execute for side effects, not for values. Using them in value position produces().letis a statement, not an expression. It cannot appear inside an expression. The right side ofletis an expression; the binding itself does not produce a value.- Blocks can be used for scoping. A block expression introduces a new scope, and variables declared inside are not visible outside.
- The distinction is uniform across the language. There is no special case for
ifversusmatchversusloop; they all follow the same expression rules, which is what makes the language composable.
Remember: The statement-versus-expression distinction is the key to reading Rust syntax. An expression produces a value; a statement performs an action. A function body is a block, and a block’s value is its final expression without a semicolon. Adding a semicolon discards the value and makes the block’s value (), which is why the compiler complains about a type mismatch when the semicolon is unintentional. Control flow constructs — if, match, loop — are expressions, so they can be assigned, passed, and nested. let is a statement, so it cannot appear inside an expression. Once you internalize this model, most Rust syntax errors become easy to diagnose: the semicolon is either present and the value is discarded, or absent and the value is returned. The rest of the language follows from that rule.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!