Everything Is an Expression
In many languages, statements like if and case just control which code runs — they don’t produce anything you can use. Ruby is different: almost every construct in the language is an expression, meaning it evaluates to a value you can assign, return, or pass around. This single idea explains a huge amount of idiomatic Ruby code, and misunderstanding it is the source of several classic bugs.
Overview / How it works
In Ruby, a statement in the traditional sense barely exists. Control-flow constructs like if/elsif/else, case/when, case/in, begin/rescue, and even while loops all evaluate to a value. So do method definitions (a def evaluates to a symbol, the method’s name), and so does assignment itself (x = 5 evaluates to 5). This is a direct consequence of Ruby’s core philosophy that almost everything is an object: 1.class is Integer, nil.class is NilClass, and even true and false are singleton instances of TrueClass and FalseClass. If everything is an object, it makes sense that every piece of code that produces a result hands back an object — there’s no separate category of \”things that just do stuff without returning anything.\”
The value an expression produces is simply the value of the last expression evaluated inside it. For an if, that’s the last line of whichever branch actually ran. For a method, it’s the last line of the method body — this is why idiomatic Ruby avoids a trailing return keyword except for early exits: the method’s last expression is already its return value. For a begin/rescue block, it’s the last line of whichever path executed, the normal body or the rescue clause. If a branch is empty or is never reached in a way that assigns anything, the result is nil — which matters a lot, since nil is one of only two falsy values in Ruby (the other being false). Everything else, including 0, \"\", and [], is truthy.
Because control-flow constructs are expressions, you can assign their result directly to a variable instead of declaring a variable first and mutating it inside each branch. This isn’t just a stylistic trick — it’s the idiomatic way to write Ruby, and it eliminates a whole class of bugs where a variable is accidentally left unassigned on some code path.
Syntax
There’s no special syntax to \”turn on\” this behavior — it’s just how these constructs work. The general pattern for capturing a value from a control-flow expression looks like this:
result = if condition
value_if_true
else
value_if_false
end
result = if ... end— the entireif/else/endblock is treated as one expression whose value is assigned toresult.- Each branch’s last line is what that branch contributes as the overall value if it’s the one that executes.
- An
ifwith no matchingelse(and whose condition is false) evaluates tonil. - The same pattern works for
case/when,case/in, andbegin/rescue/end.
Examples
Example 1: Assigning the result of an if/else
age = 20
status = if age >= 18
"adult"
else
"minor"
end
puts status
Output:
adult
Instead of declaring status = nil and reassigning it inside each branch, the whole if/else expression is assigned directly. Ruby evaluates age >= 18, takes the "adult" branch, and that string becomes the value of the entire expression, which is then stored in status.
Example 2: A method whose body is one big case expression
def grade_letter(score)
case score
when 90..100
"A"
when 80...90
"B"
when 70...80
"C"
else
"F"
end
end
puts grade_letter(85)
puts grade_letter(95)
puts grade_letter(50)
Output:
B
A
F
The method has no explicit return anywhere. Its body is a single case expression, and whatever that expression evaluates to becomes the method’s return value automatically. For 85, the 80...90 range (an exclusive range, so it stops just before 90) matches, producing "B"; for 95, the 90..100 inclusive range matches, producing "A"; 50 falls through to else, producing "F".
Example 3: begin/rescue as an expression
def safe_divide(a, b)
result = begin
a / b
rescue ZeroDivisionError
Float::INFINITY
end
result
end
puts safe_divide(10, 2)
puts safe_divide(10, 0)
Output:
5
Infinity
A begin/rescue/end block is itself an expression, just like if and case. When a / b succeeds, its value flows out as the value of the whole begin block. When it raises ZeroDivisionError, Ruby jumps to the matching rescue clause, and that clause’s last expression, Float::INFINITY, becomes the value instead. Either way, result ends up holding whichever value actually got produced.
How it works step by step
Because assignment is also an expression, assignments can be chained and nested. Consider this:
def demo
x = (y = 5) + 1
puts x
puts y
end
demo
Output:
6
5
Step by step: Ruby first evaluates the innermost expression, y = 5. This does two things — it assigns 5 to y, and, because assignment is an expression, it also evaluates to 5 as its result. That result then has 1 added to it, producing 6, which is assigned to x. This is the same mechanism behind if and case: an inner expression is evaluated first, its result bubbles outward, and an enclosing assignment captures whatever bubbles out last. Understanding this evaluation order is the key to predicting what any expression-based construct will produce, and it’s also what makes the shared-reference mistake in the next section possible.
Common Mistakes
Mistake 1: Assuming a method returns what it prints
puts always returns nil, no matter what you pass it. If puts is the last line of a method, that method returns nil too — not the string that got printed.
def loud(msg)
puts msg.upcase
end
result = loud("hello")
puts result.inspect
Output:
HELLO
nil
It looks like loud should hand back the uppercased string, but its last expression is puts msg.upcase, whose value is nil — the uppercased string was only ever printed, never returned. The fix is to make the value you want the last expression in the method, and print separately if you also want output:
def loud(msg)
msg.upcase
end
result = loud("hello")
puts result
Output:
HELLO
Mistake 2: An if with no else silently produces nil
x = 5
label = if x > 10
"big"
end
puts label.inspect
Output:
nil
Since x > 10 is false and there’s no else branch, the whole if expression evaluates to nil, and that’s what gets assigned to label. This is easy to miss because it doesn’t raise an error — the bug just quietly propagates nil downstream. Always give an if you’re using for its value an explicit else:
x = 5
label = if x > 10
"big"
else
"small"
end
puts label.inspect
Output:
"small"
Mistake 3: Chained assignment shares one object
first = second = []
first << "x"
puts second.inspect
Output:
["x"]
Because assignment is an expression, first = second = [] is really first = (second = []): Ruby creates one array, assigns it to second, and that assignment's result (the same array object) is then assigned to first. Both variables point at the identical object in memory, so mutating it through first is visible through second too. If you wanted two independent arrays, create them separately:
first = []
second = []
first << "x"
puts second.inspect
Output:
[]
Best Practices
- Assign the result of an
iforcasedirectly instead of declaring a variable and reassigning it in every branch — it's shorter and impossible to leave a branch unassigned by accident. - Let methods end with the expression you want returned, and avoid a trailing bare
return; reservereturnfor genuine early exits. - When a control-flow expression's value matters, always give it a complete set of branches (an
else, or anelseincase) so you never get a silent, unintendednil. - Remember
puts,print, andphave their own return values (nil,nil, and the printed object respectively) — don't rely on a method whose last line is aputscall to hand back useful data. - Be deliberate with chained assignment (
a = b = value); use it only when you actually want multiple names bound to the exact same object, since mutating methods like<<orpushwill affect every name that shares it. - Use
case/whenfor matching simple values or classes, and reservecase/in(pattern matching) for destructuring arrays and hashes — both are expressions, but they solve different problems.
Practice Exercises
- Write a method
describe_number(n)that uses anif/elsif/elseexpression as its entire body to return the string"negative","zero", or"positive"depending onn, with no explicitreturnanywhere. Test it with-3,0, and7. - Rewrite this snippet so it no longer silently produces
nil:msg = case 5 when 1..3 then "low" end. Add whatever branch is missing so every possible input has a defined result. - Predict the output of the following before running it, then check yourself:
a = b = [1, 2]; b.push(3); puts a.inspect. Explain in one sentence whyachanges even though you only modifiedb.
Summary
- In Ruby,
if,case/when,case/in,begin/rescue, and assignment are all expressions that evaluate to a value, not mere statements. - A construct's value is the value of the last expression evaluated on whichever path actually ran.
- Methods return the value of their last expression automatically, which is why idiomatic Ruby avoids an unnecessary trailing
return. - Only
nilandfalseare falsy in Ruby; a missingelsebranch quietly evaluates tonil, which can propagate bugs silently. putsandprintalways returnnil, so a method ending inputsdoes not return the printed value.- Chained assignment (
a = b = value) binds every name to the same object, so mutating methods affect all of them.
