Skip to content

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.

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, and match;
  • use exhaustive matching to encode a complete policy decision;
  • recognize when a loop, iterator, or pure expression is the clearer form.

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.

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.

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.

  • prefer immutable bindings;
  • use mut for 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.

Compare:

fn reserve(budget: CallBudget) -> CallBudget

with:

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 str

is more meaningful than:

fn route_label(remaining: u32, flag: bool) -> &'static str

The 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.

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.

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:

  1. zero calls always stops;
  2. any remaining budget plus high risk goes to review;
  3. one low-risk call becomes the final small-model attempt;
  4. 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.

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.

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:

  • for for a known iterator or range;
  • while while a condition remains true;
  • loop when exits are event-driven and explicit.

An unbounded agent loop is a product risk even though the Rust is valid.

From rust/rust-ai-engineering:

Terminal window
cargo run -p mosaic-domain --example expressions

Expected output:

remaining calls: 1

The assertions also prove that reservation returns a new budget without changing the original.

Look for a trailing semicolon on the block’s intended result or a branch that does not return the same type.

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.

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.

Choose checked, saturating, wrapping, or overflowing arithmetic based on the domain. Budgets usually want checked validation plus saturating display—not silent wrapping.

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.

  1. What value does a block with a semicolon after its final arithmetic expression produce?
  2. Why can shadowing change a value’s type while mutation cannot?
  3. Why does match-arm order affect the route policy?
  1. Replace high_risk: bool with RiskClass::{Low, Medium, High} and define all routes.
  2. Add estimated_cost_micros to the budget and a checked reserve_cost operation.
  3. Return a Route enum rather than &str, then render the label separately.
  4. Add tests for zero calls, one call, high risk, and invalid configuration.
  1. Should human review be permitted when the model-call budget is zero? Write both policies and their product consequences.
  2. Decide whether a cancelled run consumes the reservation for an in-flight provider call.
  3. Design a single combined budget without letting one dimension hide another.

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.

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.

  • run the companion example;
  • explain every match arm and why its order is intentional;
  • add a RiskClass version with tests;
  • distinguish a statement from an expression;
  • explain the unit type error caused by a semicolon;
  • choose arithmetic behavior deliberately for a budget.