JAVA / BRANCHING AND SWITCH EXPRESSIONS
Nested conditionals and guard clauses
Flatten deeply nested if statements into ordered guard clauses using early return, throw or continue, and know when nesting still fits.
What you will learn
- Rewrite nested ifs as early-return guards so the happy path sits at one indent level
- Order guards so each one makes the next safe, e.g. null check before a method call
- Use continue as the loop-body guard and throw for rejecting invalid arguments
- Spot when collapsing nesting with && merges two distinct failures into one answer
Understanding Nested conditionals and guard clauses
An if inside an if encodes the word "and" as geometry. Every level adds one condition the reader must hold in mind to know when a line runs, and the statement that actually does the work ends up furthest from the left margin, wrapped in a stack of closing braces. Four levels deep, the answer to "when does this line execute?" is spread over four separate lines of the file.
A guard clause inverts that shape. You test the negation of a precondition and leave immediately with return, throw, continue or break, which means every line below the guard runs only when the guard did not fire. That is the mental model worth keeping: each guard permanently establishes a fact, the guards together funnel the possible states down to one, and the final statement is the single case that survived. The else branches disappear because the jump already did the job an else would have done.
Because guards execute top to bottom, their order carries meaning: the null check must come before any guard that dereferences the value, exactly like the left-to-right evaluation that makes name != null && !name.isBlank() safe inside one condition. Guards also need somewhere to jump to, so they live in method and loop bodies, not inside an expression, where you still reach for && or a ternary. Nesting is not a defect to eliminate on sight; keep it when each level has genuinely different work in its else rather than just a different rejection message.
public class Guards {
// Nesting encodes "and" as indentation: the real answer is four levels deep.
static String nested(String name, int age, boolean verified) {
if (name != null) {
if (!name.isBlank()) {
if (age >= 18) {
if (verified) {
return "welcome " + name;
} else {
return "verify your email";
}
} else {
return "too young";
}
} else {
return "blank name";
}
} else {
return "no name";
}
}
// Guards reject each bad state and leave; what survives is the happy path.
static String guarded(String name, int age, boolean verified) {
if (name == null) return "no name";
if (name.isBlank()) return "blank name";
if (age < 18) return "too young";
if (!verified) return "verify your email";
return "welcome " + name;
}
static void compare(String name, int age, boolean verified) {
System.out.println(nested(name, age, verified) + " | " + guarded(name, age, verified));
}
public static void main(String[] args) {
compare(null, 30, true);
compare(" ", 30, true);
compare("Ada", 12, true);
compare("Ada", 30, false);
compare("Ada", 30, true);
}
}A guard clause negates a precondition and exits early, so the code after it can assume that condition holds and no longer needs to be nested inside it.
Worked examples
continue as a loop guard
Shows the loop-body equivalent of an early return, replacing three nested ifs around one line of real work.
public class Skip {
public static void main(String[] args) {
String[] rows = {"7", null, " ", "abc", "-3", "12"};
int total = 0;
for (String row : rows) {
if (row == null) continue;
String t = row.trim();
if (t.isEmpty()) continue;
if (!t.matches("\\d+")) continue;
total += Integer.parseInt(t);
}
System.out.println("total = " + total);
}
}Example explained
Line 1if (row == null) continue; abandons this iteration only, so it is the loop's version of an early return.
Line 2Each later guard may assume the earlier ones passed, which is why Integer.parseInt never sees null, blank or non-digit text.
Line 3The regex \d+ rejects "-3", so the guards define the accepted shape rather than repairing bad input.
Line 4Only "7" and "12" reach the addition, giving 19.
Throwing guards protect state
Demonstrates argument validation with throw, and why the guards must sit before the first mutation.
public class Deposit {
private long cents;
void deposit(long amount, String currency) {
if (currency == null) throw new IllegalArgumentException("currency required");
if (!currency.equals("EUR")) throw new IllegalArgumentException("unsupported: " + currency);
if (amount <= 0) throw new IllegalArgumentException("amount must be positive");
cents += amount;
}
public static void main(String[] args) {
Deposit acc = new Deposit();
acc.deposit(500, "EUR");
System.out.println("balance = " + acc.cents);
try {
acc.deposit(-1, "EUR");
} catch (IllegalArgumentException e) {
System.out.println("rejected: " + e.getMessage());
}
System.out.println("balance = " + acc.cents);
}
}Example explained
Line 1The null guard comes first, so currency.equals("EUR") on the next line cannot throw NullPointerException.
Line 2throw leaves the method just as return does, which is why none of these guards needs an else.
Line 3cents += amount; is the only mutation and it follows every guard, so the rejected call cannot half-apply.
Line 4The second balance print is still 500, proving the guard fired before any state changed.
When collapsing with && loses information
Shows that folding two nested conditions into one && test is safe but merges two different failures into one answer.
public class Collapse {
static String nested(int[] data, int i) {
if (data != null) {
if (i < data.length) {
return "value " + data[i];
} else {
return "index out of range";
}
}
return "no data";
}
static String collapsed(int[] data, int i) {
if (data != null && i < data.length) {
return "value " + data[i];
}
return "no data";
}
public static void main(String[] args) {
int[] d = {10, 20};
System.out.println(nested(d, 5) + " / " + collapsed(d, 5));
System.out.println(nested(null, 0) + " / " + collapsed(null, 0));
System.out.println(nested(d, 1) + " / " + collapsed(d, 1));
}
}Example explained
Line 1data != null && i < data.length is safe because && stops at the first false operand, the same protection the outer if gave by nesting.
Line 2collapsed() answers "no data" for an out-of-range index, silently losing the distinction the nested version reported.
Line 3To flatten without losing it, use two guards: if (data == null) return "no data"; then if (i >= data.length) return "index out of range";
Important notes
In a void method the guard is a bare return; on its own line, with no value after it.
If every path needs shared work afterwards, such as closing a resource or logging a result, scattered returns will skip it; keep one exit point or use try/finally instead.
Common mistakes
Placing the null guard after a guard that dereferences the value, as in if (name.isBlank()) ... then if (name == null) ...; the first guard throws NullPointerException, so the null guard never runs.
Writing a guard with no jump, such as if (age < 18) System.out.println("too young"); with no return after it; execution continues into the happy path and the caller gets the rejection and the success result.
Turning if (a) { if (b) X else Y } else Z into if (a && b) X else Z and dropping Y; two different failures now produce one answer, which is a behaviour change, not a cleanup.
Try it yourself
Change, predict, then run
Write static String check(String path, long size) twice, once with nested ifs and once with guard clauses, returning "no path" for null, "blank path" for blank, "too big" above 1000000 and otherwise "ok: " + path. Print both versions side by side for null, " ", "a.txt" with size 0, and "a.txt" with size 2000000 and confirm the pairs match.
Open the Java workspaceCheck your understanding
A method starts with if (s.isEmpty()) return "empty"; and the next line is if (s == null) return "none";. What happens when it is called with null?
- It returns "none", because the null guard still catches the argument.
- It returns null unchanged, since neither guard matches.
- It throws NullPointerException on the first guard, before the null check is ever reached.
- It fails to compile, because the null check can never be reached.
Show answer
Guards run top to bottom, so s.isEmpty() dereferences null first and throws; the null guard is dead in practice only because of the value passed in. Option 3 is tempting, but javac's unreachable-statement rule is about control flow, not about runtime values, and nothing before the null check jumps unconditionally, so the compiler considers it reachable and the fix is to reorder the two guards.