Expressions, Bindings, and Control Flow
Rust is primarily an expression language. That fact explains function returns, blocks, if, match,
and several compiler messages that otherwise feel arbitrary.
This chapter builds one small but real policy component: an immutable model-call budget and a route
decision. The complete runnable example is
rust/rust-ai-engineering/crates/mosaic-domain/examples/expressions.rs.
Learning objectives
Section titled “Learning objectives”After completing this chapter, you should be able to:
- distinguish a binding, value, expression, and statement;
- explain how a semicolon changes a block’s value;
- use immutable bindings,
mut, and shadowing deliberately; - write functions whose signatures state their fallible behavior;
- return values from blocks,
if, andmatch; - use exhaustive matching to encode a complete policy decision;
- recognize when a loop, iterator, or pure expression is the clearer form.
What this chapter depends on
Section titled “What this chapter depends on”You need only basic programming concepts: values, functions, and branches. Ownership begins in a later
chapter. We use small Copy values here so control flow is the only new problem.
First principle: an expression produces a value
Section titled “First principle: an expression produces a value”The Rust Reference describes Rust as
primarily an expression language.
Literals, arithmetic, function calls, blocks, if, and match can all produce values.
let base = 2;let doubled = base * 2;let calls = if high_risk { 1 } else { 3 };The right side of every binding is an expression. if does not merely direct control; it yields the
selected branch’s value.
Both branches must produce a compatible type:
let route = if high_risk { "human-review"} else { "small-model"};This does not compile:
let route = if high_risk { "human-review"} else { 2};The variable cannot be both &str and integer. Rust rejects the ambiguity before runtime.
Statements sequence work
Section titled “Statements sequence work”A let binding is a statement. An expression followed by ; becomes an expression statement whose
value is discarded.
let remaining = { let allowed = 3; let used = 1; allowed - used};The block evaluates to 2 because its final expression has no semicolon.
Add one:
let remaining = { let allowed = 3; let used = 1; allowed - used;};Now the block evaluates to (), the unit value. remaining has type (), not an integer.
This is the same rule used by functions:
fn remaining(allowed: u32, used: u32) -> u32 { allowed.saturating_sub(used)}The final expression is returned. Writing return is valid and useful for an early exit, but the
normal result path is usually the final expression.
Bindings are immutable by default
Section titled “Bindings are immutable by default”let allowed = 3;The binding cannot be reassigned. Immutability makes local reasoning easier: after reading the binding, you know later code did not quietly change it.
Use mut when one identity genuinely changes:
let mut used = 0;used += 1;Use shadowing when a transformation creates a new value that deserves the same conceptual name:
let input = " 128 ";let input = input.trim();let input: u64 = input.parse()?;Each input is a new binding. Shadowing can change type; mutation cannot.
Decision rule
Section titled “Decision rule”- prefer immutable bindings;
- use
mutfor a clear local accumulation or state change; - use shadowing for a short, linear transformation;
- use different names when old and new meanings both matter.
Do not use shadowing across a long function where it makes the active value hard to identify.
Function signatures are contracts
Section titled “Function signatures are contracts”Compare:
fn reserve(budget: CallBudget) -> CallBudgetwith:
fn reserve(budget: CallBudget) -> Result<CallBudget, BudgetError>The second signature states that reservation may fail and that the caller must handle the result.
Rust’s Result carries the success value or a typed error; ? propagation comes in
Types, Errors, and Valid States.
Parameters and return types should express the domain, not only the representation:
fn route_label(budget: CallBudget, high_risk: bool) -> &'static stris more meaningful than:
fn route_label(remaining: u32, flag: bool) -> &'static strThe next improvement is to replace the boolean with a RiskClass enum. Booleans are often unclear at
call sites:
route_label(budget, true);What is true? An enum answers.
Build an immutable budget
Section titled “Build an immutable budget”The runnable example defines:
#[derive(Debug, Clone, Copy, PartialEq, Eq)]struct CallBudget { allowed: u32, used: u32,}remaining uses saturating_sub:
fn remaining(self) -> u32 { self.allowed.saturating_sub(self.used)}Ordinary unsigned subtraction can underflow when used > allowed. Saturation returns zero. That is
appropriate for the read-only question “how many calls remain?” The constructor or validator should
separately reject an impossible budget state; saturation must not hide invalid configuration.
Reservation returns a new budget:
fn reserve(self) -> Result<Self, &'static str> { if self.remaining() == 0 { Err("model-call budget exhausted") } else { Ok(Self { used: self.used + 1, ..self }) }}..self fills allowed from the previous value. Because CallBudget is Copy, using it after the
call is allowed. Larger non-Copy domain values will move; ownership explains that distinction.
The method is transactional at the value level: failure leaves the caller’s original value unchanged.
match turns alternatives into a value
Section titled “match turns alternatives into a value”Routing needs two inputs: calls remaining and whether risk is high.
fn route_label(budget: CallBudget, high_risk: bool) -> &'static str { match (budget.remaining(), high_risk) { (0, _) => "stop", (_, true) => "human-review", (1, false) => "small-model-final-attempt", (_, false) => "small-model", }}Patterns are tested top to bottom:
- zero calls always stops;
- any remaining budget plus high risk goes to review;
- one low-risk call becomes the final small-model attempt;
- other low-risk cases use the small model.
_ means “any value.” The match is exhaustive; every pair of u32 and bool values reaches one arm.
The compiler checks that property.
Order is policy. If (_, true) came first, a high-risk task with zero budget would return
human-review instead of stop. Neither order is universally correct. The code must match the
product’s terminal-state definition.
if, if let, let...else, and match
Section titled “if, if let, let...else, and match”Use if for boolean conditions:
if budget.remaining() == 0 { return Err(BudgetError::Exhausted);}Use match when multiple variants or destructured alternatives matter.
Use if let when one pattern is interesting and all others share a small fallback:
if let Some(deadline) = task.deadline { enforce(deadline)?;}Use let...else to keep the success path unindented:
let Some(object) = value.as_object() else { return Err(Violation::expected_object());};The else branch must diverge—return, break, continue, or panic—because execution after the statement
assumes the pattern matched.
Loops are expressions too
Section titled “Loops are expressions too”loop can produce a value through break:
let verified = loop { let candidate = generate()?; if verifier.accepts(&candidate) { break candidate; }};In a real harness this is incomplete because it has no budget. Bounded loops should make their limit visible:
for attempt in 1..=budget.max_model_calls { // one bounded attempt}Use:
forfor a known iterator or range;whilewhile a condition remains true;loopwhen exits are event-driven and explicit.
An unbounded agent loop is a product risk even though the Rust is valid.
Run the example
Section titled “Run the example”From rust/rust-ai-engineering:
cargo run -p mosaic-domain --example expressionsExpected output:
remaining calls: 1The assertions also prove that reservation returns a new budget without changing the original.
Failure investigation
Section titled “Failure investigation”“Expected u32, found ()”
Section titled ““Expected u32, found ()””Look for a trailing semicolon on the block’s intended result or a branch that does not return the same type.
“If may be missing an else clause”
Section titled ““If may be missing an else clause””An if without else evaluates to (). If the surrounding context expects another type, supply an
else with the same type or use an early return.
“Non-exhaustive patterns”
Section titled ““Non-exhaustive patterns””The compiler found an input not covered by match. Add the meaningful domain case. Avoid reaching
immediately for _; a wildcard can hide a newly added enum variant that deserves policy.
Arithmetic panic or wrap
Section titled “Arithmetic panic or wrap”Choose checked, saturating, wrapping, or overflowing arithmetic based on the domain. Budgets usually want checked validation plus saturating display—not silent wrapping.
Security and performance implications
Section titled “Security and performance implications”Control flow enforces budgets only if every billable path passes through it. Provider adapters must not retry internally behind the harness’s accounting.
Small expression-oriented value types are cheap and easy to test. Do not compress a complex policy into one clever expression. Clarity is a performance feature for incident response.
Exercises
Section titled “Exercises”Recall
Section titled “Recall”- What value does a block with a semicolon after its final arithmetic expression produce?
- Why can shadowing change a value’s type while mutation cannot?
- Why does match-arm order affect the route policy?
Implementation
Section titled “Implementation”- Replace
high_risk: boolwithRiskClass::{Low, Medium, High}and define all routes. - Add
estimated_cost_microsto the budget and a checkedreserve_costoperation. - Return a
Routeenum rather than&str, then render the label separately. - Add tests for zero calls, one call, high risk, and invalid configuration.
Design
Section titled “Design”- Should human review be permitted when the model-call budget is zero? Write both policies and their product consequences.
- Decide whether a cancelled run consumes the reservation for an in-flight provider call.
- Design a single combined budget without letting one dimension hide another.
Solution guidance
Section titled “Solution guidance”Use a match on (remaining_calls, risk) and list High explicitly. Return a domain enum so later
code cannot depend on display strings.
For cost, use checked_add and return a typed overflow or exhaustion error. Money should use integer
minor units rather than floating point.
A combined budget should retain separate fields for calls, units, time, tools, and cost. “Remaining budget” is not one scalar unless the product has explicitly defined an exchange rate.
Check your understanding
Section titled “Check your understanding”Why does removing a semicolon return a value?
The final expression of a block becomes the block’s value. A semicolon turns it into an expression
statement and discards that value, leaving unit ().
Why prefer a Route enum over a string?
The enum limits results to known alternatives and lets later matches be exhaustive. A string accepts typos and unrecognized states that the compiler cannot find.
Completion checklist
Section titled “Completion checklist”- run the companion example;
- explain every match arm and why its order is intentional;
- add a
RiskClassversion with tests; - distinguish a statement from an expression;
- explain the unit type error caused by a semicolon;
- choose arithmetic behavior deliberately for a budget.