The classic switch statement works, but it carries baggage from the 1970s: missing break causes silent fall-through bugs, you can't return a value from it directly, and declaring variables across cases requires scope hacks. Java 14 added a redesigned switch that fixes all of this. It introduces arrow-form cases that don't fall through, lets switch produce a value like an expression, supports comma-separated multi-label cases, adds the yield keyword for block bodies, and asks the compiler to verify that an expression form covers every possibility. This lesson walks through each of those changes with e-commerce examples and shows when to use the new form and when to stay with the classic one.
Before we look at the new syntax, it helps to name the problems the new form is solving. Each one shows up in real codebases, and the new form was designed specifically to make these mistakes impossible.
Problem 1: missing `break` causes fall-through. This is the bug from the classic switch lesson. If you forget a break, control flows into the next case and overwrites your value.
The author wanted 10, got 0. Every case fell through, and default overwrote everything with 0.
Problem 2: a switch statement can't return a value. If you want a discount variable to come out of a switch, you've to declare it before the switch, assign to it in each case, and trust yourself to handle every branch.
The variable lives outside the switch, gets assigned inside it, and there's no compiler check that you assigned a value in every branch. Forget one and the compiler complains about "may not have been initialized," which forces you to add a default or write int discount = 0; up top, hiding bugs where a real case wasn't handled.
Problem 3: every case repeats `break`. It's boilerplate that exists only to opt out of a default behavior that's rarely wanted. Reading a switch with twelve break statements is harder than reading one without them.
Problem 4: variables declared in cases need scope hacks. As the classic switch lesson covered, two cases that declare a variable with the same name require {} braces around each case body. The braces are noise that exists only to work around a quirk of fall-through scoping.
The new switch form (arrow cases, expressions, yield, exhaustiveness) was designed to make every one of these problems either impossible or loud.
breakThe simplest change is the new case syntax. Instead of case "X": followed by a body that runs until the next break, you write case "X" -> followed by a single statement or expression that runs and then exits the switch. No fall-through, ever.
Read the case bodies down the right side. No break, no semicolons hanging in space, no chance that SHIPPED falls through to DELIVERED. The arrow means "if this matches, run exactly this and you're done."
The two forms compared side by side at the dispatch level:
The classic form has that extra break found? loop because forgetting a break is what fall-through means. The arrow form removes the loop entirely. After the matched body runs, control jumps to the end of the switch. There's no way to fall through, even on purpose, with arrow syntax.
The right side of an arrow case can be one of three things: a single expression statement (like the println calls above), a throw statement, or a block in braces. For one-line bodies, the arrow form reads as a clean dispatch table, which is what most switches want to be.
In the classic form, sharing a body between cases meant stacking labels with no code in between:
The arrow form replaces that with a comma-separated list on a single line:
"STANDARD" and "ECONOMY" both match the first arrow and run the same body. The intent is right there in one line: either of these values means $5.00. There's no chance of accidentally inserting code between the labels and confusing the next reader.
The comma works for any number of labels:
Four statuses share the in-progress body. The comma list reads naturally as "any of these."
This is the bigger change. In addition to being a statement that runs side effects, switch can now be an expression that produces a value. You write it on the right side of = and the matched case's value becomes the result.
Three details. First, the switch is on the right side of =, same place as any other expression. Second, each arrow case produces a number, not a side effect. Third, the whole thing ends with a semicolon, because int discount = switch (...) { ... }; is a single statement (a variable assignment whose right-hand side happens to be a switch expression).
The expression form fixes the boilerplate from the classic form. Instead of declaring discount first, assigning to it in every case, and relying on default to catch the rest, you bind discount once to whatever the switch evaluates to. There's no chance that a case forgets to assign, because every case must produce a value. The compiler enforces it.
It also works with var, which is convenient when the type is obvious from the cases:
All four arms produce String, so var infers String. The code reads as "compute group from orderStatus by dispatching on these cases."
A switch expression is no slower than the classic form. The compiler generates the same tableswitch or lookupswitch bytecode internally. The benefits are at the source level: fewer ways to write a bug, less boilerplate, and a value comes out the end.
Sometimes one case needs more than a single expression. Maybe it logs, computes an intermediate value, and then returns a result. The arrow's right-hand side can be a {} block, and inside that block you use yield to say "this is the value of the case."
The GOLD case runs three statements, then yield baseDiscount + bonusForGold; produces the value (25). The other cases stay one-liners and don't need yield. You mix and match per case.
Two rules to remember about yield:
yield is still a valid variable or method name (the language designers call this a "contextual keyword"). You can have a method called yield in your code and the compiler won't complain.Another example using yield for a shipping cost that depends on weight inside one branch:
The OVERNIGHT arm needs a calculation, so it uses a block and yields the final number. Every other arm produces a value with a simple expression.
The diagram traces what yield does inside the block: run any setup statements, then produce one value that becomes the result of the whole switch expression. Think of yield as "return, but only from this case, not from the surrounding method."
When switch is an expression, the compiler insists that every possible value of the input is handled. If it's not, the code doesn't compile. This is what "exhaustiveness" means: every reachable input must lead to a value, no exceptions.
For a String selector, exhaustiveness almost always means including a default, because String has infinite possible values. The compiler can't prove that "BRONZE", "SILVER", "GOLD" are the only inputs that will appear.
For an enum selector, exhaustiveness can be reached without default, because the compiler knows the full list of constants. Cover them all and you're done.
No default and the code compiles. The five constants in OrderStatus are listed exactly once each. The compiler walks the enum, checks the cases, and confirms full coverage.
Drop one of them and the code stops compiling. What happens if you forget CANCELLED:
The compiler reports something like:
This is one of the strongest reasons to prefer the expression form with enums. If someone adds a new constant to OrderStatus later (say, REFUNDED), every exhaustive switch expression on that enum becomes a compile error, pointing to the places that need updating. The classic switch statement would ignore the new constant without warning.
You can still add a default to an enum switch expression if you want a catch-all, but it's no longer the only way to satisfy the compiler. Many codebases skip default on exhaustive enum switches specifically to get the compile-time safety net.
The diagram traces the compiler's logic. Cover every constant or supply a default. Either path produces a complete switch expression. Neither path leaves a hole.
A small note on Java 21: the same exhaustiveness rule also covers case null and sealed-type hierarchies. For now, treat exhaustiveness as "all enum constants OR a default for any other type."
Three forms now coexist in modern Java: the classic colon-syntax statement, the arrow-syntax statement (no value), and the switch expression (produces a value). They're easy to mix up. This table summarizes the differences.
| Feature | Classic statement | Arrow statement | Switch expression |
|---|---|---|---|
| Syntax | case X: ... break; | case X -> ...; | var v = switch (...) { case X -> value; }; |
| Returns a value | No | No | Yes |
| Fall-through | Yes (default behavior) | No | No |
Needs break | Yes, on every case | No | No |
| Multi-label cases | Stacked labels | Comma list | Comma list |
| Block body | Inside {}, ends with break | Inside {}, no break | Inside {}, must yield |
| Exhaustiveness check | No | No | Yes |
default required | Optional | Optional | Required unless enum is fully covered |
| Variable scope | Shared across cases | Per-case scope | Per-case scope |
| Available since | Java 1.0 (Java 7 for String) | Java 14 | Java 14 |
A simple rule of thumb: if you want a value out, use the expression form. If you want side effects with no value, use the arrow statement form. Use the classic colon form when you're in an existing codebase that hasn't migrated, or for the rare case where intentional fall-through is exactly what you want.
The classic colon syntax still works. You don't have to rewrite anything. Existing code keeps compiling and behaving as before. The new arrow and expression forms are additions, not replacements.
You can technically mix colon-syntax and arrow-syntax cases within the same switch:
This compiles, but don't do it. The mixed style reads as a mess: some cases have break, others don't, and the reader has to track which syntax each case uses to know whether fall-through can happen. Pick one form per switch and stick with it.
A simple convention works well: arrow form for new code, classic form when editing inside an existing classic switch. If you start refactoring one case, refactor them all together. Half-converted switches are worse than either pure form.
Switch expressions work well when each case produces a value: a discount percent, a status label, a shipping cost. They're a poor fit when each case has different side effects and no value to return.
A case that fits the expression form. Each branch produces a number.
Clean, one variable comes out, every case is a tiny arrow.
A case where the arrow statement form fits, not the expression form. Each branch runs different side-effect code and doesn't compute a single value.
Each case fires off different actions. There's no single value to compute, so wrapping the switch in var x = ... would be awkward. The arrow statement form keeps the dispatch-table feel of the expression form (no break, no fall-through, multi-label cases) without forcing you to return a value you don't have.
The choice between expression and statement is really the choice between "every case produces a value of the same type" and "every case does different things." If you find yourself writing yield; with no value, or every arm calling a void method, that's a sign the statement form is the better fit.
case null and Pattern MatchingJava 21 added two more upgrades to switch: a special case null label that lets you handle null inputs without a separate if check, and pattern matching that lets case labels be types (case Integer i ->) or deconstructed records.
For now, the rule is simple: if you switch on a String or other reference type that can be null, the switch still throws NullPointerException exactly like the classic form. Guard with an if before the switch.
The if check runs before the switch, so the switch never sees null.
10 quizzes