123. Functions and expressions
In TypeScript, if is a statement, so you reach for the ternary. In Rust, if, blocks and whole function bodies produce values, and one semicolon decides whether they do.
- Write Rust functions with typed parameters and a return type.
- Use if and blocks as expressions that produce values.
- Explain how a missing or extra semicolon changes what a block returns.
- Read the compiler errors for mismatched branch types and stray semicolons.
The ternary you never liked
You want a label that depends on a number. In TypeScript:
let label: string;
if (n < 0) {
label = 'negative';
} else {
label = 'not negative';
}
Four lines, a variable that has to be let, and a moment where it holds
nothing. So you write a ternary instead: const label = n < 0 ? 'negative' : 'not negative';. The ternary exists because if is a
statement. It does something but has no value.
Chapter 31 drew that line through Drip’s own grammar: expressions produce values, statements don’t. Rust moves most things to the expression side:
let label = if n < 0 {
"negative"
} else {
"not negative"
};
No ternary, no mut, and label is never empty.
In TypeScript, a block of code is a room you walk through. In Rust, most blocks are vending machines: you walk through, and something drops out the bottom, namely whatever the last line was. Put a semicolon on that last line and the machine drops nothing.
Blocks have values
Any { ... } block is an expression. Its value is its last expression,
written without a semicolon:
let y = {
let a = 3;
a + 1
};
// y is 4
let a = 3; is a statement, and it ends with a semicolon. a + 1 has
none, so it is the block’s value. Put a semicolon after it and it
becomes a statement too. The block then has the value (), the
unit value, Rust’s “nothing” (much like void).
let itself is a statement, so let x = let y = 6; is an error. In
JavaScript, x = y = 6 works, because assignment is an expression.
Rust is expression-oriented. A block { ... } evaluates to its
final expression, or to () if it ends in a statement. if is an
expression: every branch must produce the same type, and an if
without else produces (). Adding ; after an expression turns it
into a statement and throws its value away.
Both branches of an if must agree:
error[E0308]: `if` and `else` have
incompatible types
--> f2.rs:3:31
|
3 | let s = if b { 1 } else { "no" };
| - ^^^^
| | expected integer,
| | found `&str`
| expected because
| of this
(rustc 1.97, wrapped to fit. b is a bool.) TypeScript would happily give s the
type number | string. Rust wants one type.
Functions
You’ve seen one already. Here is the full shape:
fn add(a: i32, b: i32) -> i32 {
a + b
}
fn, a snake_case name, and parameters, each with its type. A function’s parameter types are never inferred.-> i32is the return type. Leave it out and the function returns().- The body is a block, so its final expression is the return value.
a + bwith no semicolon is thereturn.
return still exists, for leaving early:
fn safe_div(a: i32, b: i32) -> i32 {
if b == 0 {
return 0;
}
a / b
}
Idiomatic Rust uses return only for early exits, and lets the last
expression do the rest[1]. Two more differences
from TypeScript: a function can be called from code above its
definition, and there are no default or optional parameters.
A Rust function is declared with fn name(param: Type, ...) -> Ret.
Every parameter needs a type. The body’s final expression is returned;
return expr; returns early. A function with no -> returns ().
Watching a value flow out
Here is chapter 1’s clamp, the Rust way. It has no return and no
variables, just one if expression whose value becomes the function’s:
fn clamp(x: i32, lo: i32, hi: i32) -> i32 {if x < lo {lo} else if x > hi {hi} else {x}}fn main() {let r = clamp(15, 0, 10);println!("{r}");}
The last step is the famous mistake. Here it is for real, in a smaller function:
fn inc(x: i32) -> i32 {
x + 1;
}
error[E0308]: mismatched types
--> f1.rs:1:19
|
1 | fn inc(x: i32) -> i32 {
| --- ^^^ expected `i32`,
| | found `()`
| |
| implicitly returns `()` as its body
| has no tail or `return` expression
2 | x + 1;
| - help: remove this semicolon
| to return this value
The compiler even points at the semicolon. When you see “expected i32,
found ()”, look for a semicolon at the end of a block.
fn clamp(x: i32, lo: i32, hi: i32)
-> i32 {
if x < lo {
lo
} else if x > hi {
hi
} else {
x
}
}
fn main() {
let y = {
let a = 3;
a + 1
};
println!("{y}");
println!("{}", clamp(15, 0, 10));
let n = -2;
let label = if n < 0 {
"negative"
} else {
"not negative"
};
println!("{label}");
}Loops look as you’d expect, minus the parentheses: while n > 0 { ... }. Rust has no C-style for (let i = 0; ...) loop. Chapter 134
covers its for.
Your Drip VM already compiles an expression with branches. Look at
or_ in the TypeScript VM’s compiler (chapter 55): a JUMP_IF_FALSE, a
JUMP, and two paths that each leave exactly one value on the stack.
An if expression would compile the same way as Drip’s if statement,
with one change: each branch compiles an expression, not a statement,
so the value it leaves stays on the stack instead of being popped.
Expression-oriented code costs nothing extra to run.
Your turn
Write grade(score), which returns a char: 'A' for 90 and up, 'B' for 80 to 89, 'C' for 70 to 79, and 'F' below 70. Use an if / else if / else expression as the body, without return.
fn grade(score: u32) -> char {
todo!()
}Start with a number n. If it is even, halve it; if it is odd, make it 3 * n + 1. Repeat until it reaches 1. Write steps(n), which returns how many steps that takes (steps(1) is 0, steps(6) is 8). Use a while loop, and an if expression for the next value.
fn steps(n: u64) -> u32 {
todo!()
}What you learned
- Rust is expression-oriented: blocks and
ifproduce values. A block’s value is its final expression, without a semicolon. - A trailing
;turns an expression into a statement, giving(). “expectedi32, found()” usually means a stray semicolon. - Both branches of an
ifmust have the same type, and anifused as a value needs anelse. - Functions:
fn name(p: T) -> R { ... }. Parameter types are required. The final expression is the return value;returnis for early exits.
Next comes the idea that makes Rust Rust: who owns a value, and when it goes away.
Check your understanding
0/6- 1.
What is
y?let y = { let a = 2; a * 5 }; - 2.
Why doesn’t this compile?
fn double(n: i32) -> i32 { n * 2; } - 3.
Which line is a correct Rust version of TypeScript’s
const m = a > b ? a : b;? - 4.
What does
let s = if ok { 1 };(noelse) do? - 5.
What does a Rust function declared with no
->return? - 6.
Your Drip VM compiles
a or bwith jumps that leave one value on the stack. What would a Dripifexpression need beyond theifstatement’s jumps?
References
- [1] Steve Klabnik, Carol Nichols, Chris Krycho (2026). The Rust Programming Language (3rd edition). No Starch Press; free online at doc.rust-lang.org/book. doc.rust-lang.org/book/ (checked 2026-10-31)