Programming Languages · Language Design
Expression-Oriented Syntax and the Value of a Block
What changes when every construct produces a value, how a block’s trailing expression becomes its result, and why the statement form survives in languages that removed it.
A language is expression-oriented when constructs that other languages treat as statements produce values. An if produces a value; a block produces a value; a loop may produce a value. The consequence is not merely that more code can be written without a temporary variable. It changes what the grammar must contain, how a block’s end is recognised, and what a function body is.
Statements as a derived form
In a language with a statement grammar, a function body is a statement list and a return statement supplies the result. In an expression-oriented language, a function body is an expression, and the result is that expression’s value. The return keyword may still exist for early exit, but it is no longer the only way to produce a result.
The shift is mostly a change of defaults. An if in C is a statement; it has no value, and x = if (c) 1 else 2 is not valid. An if in Rust, OCaml, or Scala is an expression, and the same assignment is valid. The two branches must have compatible types, and the whole construct has that type. In C the equivalent requires the conditional operator ?:, which is the expression form of the same idea and exists precisely because the statement form does not compose.
Once if is an expression, the grammar needs a rule for what an expression statement is. An expression evaluated for its effects only is still useful, so the language keeps a form that discards the value. Rust spells this with a semicolon: an expression followed by ; becomes a statement whose value is the unit type. OCaml uses ; as a sequencing operator between expressions inside a block, with a different grammar. Both preserve the ability to write effectful code, and both make the value-producing form the unmarked one.
The trailing expression
A block is a sequence of items and statements followed by an optional final expression. The block’s value is that final expression’s value. If the final expression is absent or is terminated by a semicolon, the block’s value is unit.
That rule is small and it explains a class of error. In Rust:
fn classify(n: i32) -> &'static str {
if n < 0 {
"negative"
} else if n == 0 {
"zero"
} else {
"positive"
}
}
The function body is a block whose trailing expression is the if. Each branch is a block whose trailing expression is a string literal. No return appears, and the function’s declared return type is the type of the if. Adding a semicolon after the final "positive" would make that branch’s block evaluate to unit, and the type checker would report a mismatch between () and &str at the branch, not at the function.
The diagnostic location is a consequence of the rule. The error is reported where the value was discarded, which is the semicolon, and the message names the expected type. That is more useful than a missing-return diagnostic at the closing brace, because the brace is not where the mistake is.
The block-as-expression rule also makes a block usable anywhere an expression is expected:
let area = {
let w = width();
let h = height();
w * h
};
The block introduces a scope, performs two bindings, and yields a product. Nothing about the block’s value requires the surrounding statement to be an assignment; the same block could be an argument or an element of a collection. This is the practical benefit of expression orientation: scoping and value production compose, so a computation that needs temporaries does not need a named function or a mutable outer variable.
What the grammar has to give up
Expression orientation removes a distinction the grammar previously relied on. In a statement grammar, a statement can begin with a keyword such as if or while, and the parser knows immediately what it is parsing. In an expression grammar, if is a prefix expression, so an expression can begin with it, and the parser must handle an expression that starts with a construct that can also contain blocks.
The classic hazard is a brace that could open either a nested block or a value. In a language where { begins a block expression, an expression statement beginning with { is read as a block rather than as a value, and a head position such as an if condition is where the two readings collide. Rust resolves this by forbidding a struct expression directly in the head of an if, while, or loop, or in the scrutinee of a match, unless it is wrapped in parentheses, and by reading a { in statement position as a block. The rule is a grammar restriction rather than a semantic one, and it exists because the two readings cannot be distinguished locally.
The second hazard is the trailing-expression rule interacting with control flow. A block that ends with a return expression has type !, the never type, which is compatible with every type. The compiler accepts a block whose last expression diverges as having the expected type, so a function with one branch that returns early and another that falls through type-checks. That compatibility is what makes early return usable in an expression-oriented language; without a bottom type, the branch would need a dummy value.
The third hazard is the loop. A loop that can exit with a value, as in Rust’s break value, has the type of the value; a while loop has unit. The distinction is in the specification and it constrains how a programmer writes a search that returns a result. Languages without loop values require a mutable variable or an early return, which is a small but constant syntactic tax.
The unit type as the price
Expression orientation requires a value for constructs that have nothing to return. The unit type is that value: a type with exactly one inhabitant. A statement terminated by a semicolon has type unit, an empty block has type unit, a function that returns nothing has return type unit.
The unit type is not the same as the absence of a type. It is a real type, it can be a type parameter, and a function returning unit can be passed where a function returning unit is expected. That makes it possible to write generic code over “returns nothing” and “returns something” uniformly, which is a small but real benefit. It also means the type checker has a value to assign to every expression, so no expression is untyped.
The cost is a class of confusion at the boundary. A function whose last expression is an assignment has type unit, so a function that intends to return the assigned value must write the value as a trailing expression rather than as an assignment. A function that returns a closure must be careful that the closure’s body block yields the intended value. These are ordinary type errors, and they are reported where the value is discarded.
Languages without a unit type, or with a null-like void, handle the same cases differently. Java’s void is not a type that can be used as a value, so a method returning void cannot be assigned, and generic code cannot abstract over void. C’s void is an incomplete type with no values, which is why a void expression cannot appear in a conditional. The unit type is the expression-oriented answer: give the absence of information a value.
Where statement syntax still wins
Expression orientation is not strictly better, and the places where it loses are worth naming.
Error messages get longer, because a type mismatch inside a block is reported at the innermost expression and the reader must reconstruct which enclosing construct expected what. Statement languages report at the statement level, which is coarser but often closer to the edit the programmer made.
Debugging is affected. A statement can be a breakpoint location, and a debugger’s notion of “next line” maps to statement boundaries. In an expression-oriented language, a multi-line expression may be a single statement for stepping purposes, or may be split across several, depending on how the compiler emits line tables. The mapping is a quality-of-implementation matter, not a language rule, but it follows from the grammar.
Macros and metaprogramming interact differently. A macro that expands to statements cannot be used in expression position, and a macro that expands to an expression cannot be used where a statement is expected unless the language has a rule for expression statements. Languages with both forms must specify which positions accept which, and the specification grows accordingly.
The largest cost is familiarity. A reader trained on statement languages expects if to be a control-flow construct, and an if that returns a value requires a small adjustment. The adjustment is not hard, but it is not free, and languages that adopt expression orientation usually keep the statement form available so that code written in the older style still compiles.
The same question in other constructs
The expression-versus-statement distinction reappears wherever a construct can be used in two positions. Pattern Matching and the Exhaustiveness Check is the clearest case: a match used as an expression must be exhaustive because every path must produce a value, while a match used as a statement can fall through. The exhaustiveness rule is the typing rule for the expression form, and the statement form relaxes it.
The distinction also determines where declarations can appear. A language that allows a block anywhere an expression is expected allows a declaration inside that block, which means a temporary binding can be introduced in the middle of an expression. That is a scoping decision as much as a syntax one, and it connects to how a block’s bindings are ordered, which is a separate design question about when a name becomes usable.
The practical summary is that expression orientation is a choice about defaults. It makes value production the ordinary case and effect-only code the marked case, and it requires a unit type to fill the gap. The grammar pays for it with restrictions on where a block can begin, and the type checker pays for it with a rule that every path produces a value. Those costs are small and local, which is why the style has spread, and they are real, which is why statement syntax has not disappeared.
A block used as a value is also the building unit for most syntactic sugar. Desugaring Boundaries: Where Surface Syntax Ends describes how a construct such as for is specified as a translation into a core form, and a core with block-valued expressions is what makes such translations short enough to write down.
References
- The Rust Reference — Block expressions — The Rust project
- The OCaml Manual — Core language, expressions — INRIA
- Revised Report on the Algorithmic Language Scheme — R7RS, Scheme Steering Committee