Everything Is an Expression

Published by Programming Line in Ruby. Last updated .

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 entire if/else/end block is treated as one expression whose value is assigned to result.
  • Each branch’s last line is what that branch contributes as the overall value if it’s the one that executes.
  • An if with no matching else (and whose condition is false) evaluates to nil.
  • The same pattern works for case/when, case/in, and begin/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 if or case directly 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; reserve return for genuine early exits.
  • When a control-flow expression's value matters, always give it a complete set of branches (an else, or an else in case) so you never get a silent, unintended nil.
  • Remember puts, print, and p have their own return values (nil, nil, and the printed object respectively) — don't rely on a method whose last line is a puts call 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 << or push will affect every name that shares it.
  • Use case/when for matching simple values or classes, and reserve case/in (pattern matching) for destructuring arrays and hashes — both are expressions, but they solve different problems.

Practice Exercises

  1. Write a method describe_number(n) that uses an if/elsif/else expression as its entire body to return the string "negative", "zero", or "positive" depending on n, with no explicit return anywhere. Test it with -3, 0, and 7.
  2. 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.
  3. 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 why a changes even though you only modified b.

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 nil and false are falsy in Ruby; a missing else branch quietly evaluates to nil, which can propagate bugs silently.
  • puts and print always return nil, so a method ending in puts does not return the printed value.
  • Chained assignment (a = b = value) binds every name to the same object, so mutating methods affect all of them.