On This Page
Here's the report we're building
This is what you'll have in about twenty minutes. One small grading program, five students' submissions of four different shapes, and a few awkward raw inputs thrown at it:
AUTUMN runs 12 weeks
SPRING runs 12 weeks
SUMMER runs 8 weeks
Exam[score=91] -> 91 Distinction. Offer the honours seminar.
Coursework[marks=[78, 84, 80]] -> 81 MERIT. 9 more marks for distinction.
Project[title=Parcel router, score=72, late=true] -> 62 PASS. 18 more marks for merit.
Project[title=Library kiosk, score=72, late=false] -> 72 PASS. 8 more marks for merit.
Absent[reason=illness] -> 0 Fail. Book a resit.
describe(88) = score 88 (B)
describe(140) = out of range: 140
describe( 67 ) = score 67 (D)
describe(abc) = no value
describe() = blank
describe(null) = no value
describe(3.5) = unsupported: Double
Every line of that comes out of a switch. No if/else if ladders, no instanceof casts, no break. If you've used switch since Java 8, the code will look wrong to you at first, and then it'll look better. This tutorial is a guide to Java switch expression and pattern matching features as they work in Java 21, built on a student grades app small enough to type in one sitting.
Two things are in play, and people blur them together. A switch expression (Java 14) is a switch that produces a value, with arrow labels and no fall-through. Pattern matching for switch (Java 21) lets the labels be types, with guards, instead of only constants. We'll use both, in that order, and each step has a command you can run to check it.
I built this on a plain JDK with javac. It compiled on the first try, which is rare enough that I went and broke it on purpose, seven different ways. Those compiler messages are the troubleshooting section, and by the end you'll want them.
What you need to have installed
| Tool | Version I used | Check it with |
|---|---|---|
| JDK | OpenJDK 21.0.12.1 (Ubuntu build) | java -version |
| javac | 21.0.12.1 | javac -version |
| IntelliJ IDEA or Eclipse | optional, see the setup section | Help → About |
Anything from JDK 21 up should compile this as-is; I only ran it on 21. Pattern matching for switch is final in 21 (JEP 441), so on JDK 17 it won't compile (I forced that error below). Switch expressions arrived in 14 (JEP 361).
You should know basic Java, enums and what a record is. If records are new, read How to Use Java Records first. We'll also lean on a sealed interface, which the sealed classes tutorial covers properly. Here you only need to know it means "this interface has a fixed list of implementations the compiler can see".
Need a JDK? SDKMAN works: sdk list java and install a 21 build from the list. Or take any JDK 21 build you like.
Lay out the grades project
The primary path here is the command line, because it has the fewest moving parts and the compiler messages are the point. IDE notes come after.
Create this tree:
grades/
└── src/
├── main/java/com/example/grades/
│ ├── Band.java
│ ├── Grader.java
│ ├── Main.java
│ ├── Submission.java
│ └── Term.java
└── test/java/com/example/grades/
├── Checks.java
└── LegacySwitchDemo.java
mkdir -p grades/src/main/java/com/example/grades grades/src/test/java/com/example/grades
cd grades
To compile and run later, from the grades folder:
mkdir -p out
javac -Xlint:all -d out $(find src -name '*.java')
java -cp out com.example.grades.Main
On Windows PowerShell, swap the $(find ...) part for javac -Xlint:all -d out (Get-ChildItem -Recurse src -Filter *.java).FullName. I didn't run that variant myself.
In IntelliJ IDEA
New Project → Java, set the JDK to 21, build system IntelliJ, and untick the sample-code checkbox. Then open File → Project Structure → Project and set Language level to 21. If the level is lower, IntelliJ underlines case Exam e -> in red even though the file is fine. Right-click src/main/java, Mark Directory as → Sources Root, and src/test/java as Test Sources Root. I wrote these menu paths from JetBrains' current help pages; I didn't click through the IDE on this run.
In Eclipse
File → New → Java Project, pick a JRE 21, finish. Open Project → Properties → Java Compiler and set the compliance level to 21. Add src/main/java and src/test/java as source folders under Build Path → Configure Build Path → Source. Same caveat: from the docs, not executed.
With Maven or Gradle
Set the Java release to 21 (<maven.compiler.release>21</maven.compiler.release> in a pom.xml, or java { toolchain { languageVersion = JavaLanguageVersion.of(21) } } in build.gradle.kts). The code has no dependencies. My environment couldn't reach Maven Central on this run, so I didn't build with either tool. That's why the checks are plain Java instead of JUnit; the last section covers converting them.
Podman and Docker
Nothing here needs a container. If you want a throwaway JDK anyway: podman run --rm -it -v "$PWD":/work -w /work docker.io/library/eclipse-temurin:21-jdk bash and swap podman for docker. I didn't run that either, so check the tag exists when you do.
Write the grader, one switch at a time
Two small files first, then the real work. Grader.java grows across five steps; the complete file is at the end of Step 5, and every step's code goes into it in order.
Step 1: A switch that returns a value
Goal: map a term to its number of teaching weeks, with no break, no default, and no way to forget one.
src/main/java/com/example/grades/Term.java
package com.example.grades;
public enum Term {
AUTUMN, SPRING, SUMMER
}
src/main/java/com/example/grades/Band.java
package com.example.grades;
public enum Band {
DISTINCTION, MERIT, PASS, FAIL
}
Start Grader.java like this. The imports are for later steps; unused imports don't hurt, and you won't have to come back for them.
src/main/java/com/example/grades/Grader.java
package com.example.grades;
import com.example.grades.Submission.Absent;
import com.example.grades.Submission.Coursework;
import com.example.grades.Submission.Exam;
import com.example.grades.Submission.Project;
public final class Grader {
private Grader() { }
public static int teachingWeeks(Term term) {
return switch (term) {
case AUTUMN, SPRING -> 12;
case SUMMER -> 8;
};
}
}
Three things to notice. The whole switch sits after return, so it's an expression: it has a value, and the semicolon after the closing brace belongs to the return. case AUTUMN, SPRING is one label with two constants, which replaces the stacked case A: case B: lines. And there's no default. That's on purpose. When a switch expression covers an enum, the compiler checks that every constant is handled, and if you add WINTER later it refuses to compile until you decide what winter means. A default would turn that error into a silent wrong answer.
You can't run anything yet, since Submission doesn't exist. The checkpoint comes in Step 4.
Step 2: Switch on numbers, and see what the arrow saves you
Goal: turn a score into a letter. A score of 100 has to land in the same bucket as 90 to 99.
Add this method to Grader, below teachingWeeks:
public static char letter(int score) {
if (score < 0 || score > 100) {
throw new IllegalArgumentException("score out of range: " + score);
}
return switch (score / 10) {
case 10, 9 -> 'A';
case 8 -> 'B';
case 7 -> 'C';
case 6 -> 'D';
default -> 'F';
};
}
Dividing by ten turns 0 to 100 into 0 to 10, which makes constants usable as labels. case 10, 9 is the grouping. Here default is required: the compiler can't prove that score / 10 is only 0 to 10, so it demands a catch-all, and the guard above it makes sure bad input never reaches it silently.
Now the part I want you to see with your own eyes. This is the same logic in the old colon style, with one missing break:
src/test/java/com/example/grades/LegacySwitchDemo.java
package com.example.grades;
public class LegacySwitchDemo {
static char legacyLetter(int score) {
char letter = '?';
switch (score / 10) {
case 10:
case 9:
letter = 'A';
break;
case 8:
letter = 'B';
case 7:
letter = 'C';
break;
default:
letter = 'F';
}
return letter;
}
public static void main(String[] args) {
System.out.println("legacy 85 -> " + legacyLetter(85));
System.out.println("arrow 85 -> " + Grader.letter(85));
}
}
This demo needs Grader to compile, so run it after Step 6. When you do, the output is:
legacy 85 -> C
arrow 85 -> B
An 85 became a C. Nothing crashed and nothing warned, except -Xlint:all, which does flag it:
src/test/java/com/example/grades/LegacySwitchDemo.java:14: warning: [fallthrough] possible fall-through into case
case 7:
^
1 warning
That one-line difference is the argument for arrow labels. They don't fall through, so the bug can't be written.
Step 3: Compute inside a case with yield
Goal: produce a one-line report per band, and in two of the bands do some arithmetic first.
Add these two methods:
public static Band band(int score) {
return switch (letter(score)) {
case 'A' -> Band.DISTINCTION;
case 'B' -> Band.MERIT;
case 'C', 'D' -> Band.PASS;
default -> Band.FAIL;
};
}
public static String report(int score) {
Band band = band(score);
return switch (band) {
case DISTINCTION -> "Distinction. Offer the honours seminar.";
case MERIT, PASS -> {
int target = band == Band.MERIT ? 90 : 80;
int gap = target - score;
String next = band == Band.MERIT ? "distinction" : "merit";
yield band + ". " + gap + " more mark" + (gap == 1 ? "" : "s") + " for " + next + ".";
}
case FAIL -> "Fail. Book a resit.";
};
}
band is a switch on a char. The labels 'C', 'D' group two letters into one band. report is the interesting one. The MERIT, PASS arm has a block, and inside a block a bare value isn't allowed; you hand it back with yield. Think of yield as return for the switch expression only. The enclosing method keeps going.
I deleted the yield on purpose later to see what the compiler says (not a statement). It's in the troubleshooting list.
Step 4: Switch on types with when guards
Goal: score four different kinds of submission without a single instanceof.
src/main/java/com/example/grades/Submission.java
package com.example.grades;
import java.util.List;
public sealed interface Submission {
record Exam(int score) implements Submission { }
record Coursework(List<Integer> marks) implements Submission {
public Coursework {
marks = List.copyOf(marks);
}
}
record Project(String title, int score, boolean late) implements Submission { }
record Absent(String reason) implements Submission { }
}
Submission is sealed: the compiler knows Exam, Coursework, Project and Absent are the only implementations, because they're declared in the same file. The Coursework constructor copies its list so a caller can't change the marks afterwards (one of the checks proves it).
Add this method to Grader:
public static int finalScore(Submission submission) {
return switch (submission) {
case Exam e -> e.score();
case Coursework c when c.marks().isEmpty() -> 0;
case Coursework c -> (int) Math.round(
c.marks().stream().mapToInt(Integer::intValue).average().orElse(0));
case Project p when p.late() -> Math.max(0, p.score() - 10);
case Project p -> p.score();
case Absent a -> 0;
};
}
Read the labels top to bottom. case Exam e matches any Exam and binds it to e, already cast. case Coursework c when c.marks().isEmpty() matches a Coursework only if the guard is also true. Then comes the unguarded case Coursework c, which catches the rest. The order matters, and the compiler enforces it: put the general case above the guarded one and the guarded one is unreachable, which is a compile error (forced below).
There's still no default. Submission is sealed, all four are covered, so the switch is exhaustive. Add a fifth record to Submission and every switch like this one stops compiling until you handle it. I'd rather have that than a runtime surprise.
One more guard-related detail. In Java 21 you write when, not &&. The JDK 17 preview used && for guards, so older articles show it; if you copy code from them, change it.
Checkpoint. You have everything Main needs except describe. Add Step 5, then run it.
Step 5: Switch on Object, case null, and a safe default
Goal: take unclean input (an integer, a string from a form, a submission, null, something unexpected) and describe it without a chain of if tests.
Add the last method and its helper, then close the class:
public static String describe(Object raw) {
return switch (raw) {
case null -> "no value";
case Integer i when i < 0 || i > 100 -> "out of range: " + i;
case Integer i -> "score " + i + " (" + letter(i) + ")";
case String s when s.isBlank() -> "blank";
case String s -> describe(parse(s));
case Submission sub -> "submission worth " + finalScore(sub);
default -> "unsupported: " + raw.getClass().getSimpleName();
};
}
private static Object parse(String text) {
try {
return Integer.valueOf(text.trim());
} catch (NumberFormatException e) {
return null;
}
}
Walk through the labels. case null is allowed in Java 21 and it's the thing that finally lets a switch accept null on purpose. Without it, a null selector throws a NullPointerException, same as always. case Integer i when i < 0 || i > 100 sits above plain case Integer i, so the bad numbers get caught first. The String arm calls describe again on the parsed value; parse returns null for non-numbers, which loops back to case null. And default is required here because Object has too many subtypes to list.
If you'd rather see the complete Grader.java in one block, here it is:
src/main/java/com/example/grades/Grader.java
package com.example.grades;
import com.example.grades.Submission.Absent;
import com.example.grades.Submission.Coursework;
import com.example.grades.Submission.Exam;
import com.example.grades.Submission.Project;
public final class Grader {
private Grader() { }
public static int teachingWeeks(Term term) {
return switch (term) {
case AUTUMN, SPRING -> 12;
case SUMMER -> 8;
};
}
public static char letter(int score) {
if (score < 0 || score > 100) {
throw new IllegalArgumentException("score out of range: " + score);
}
return switch (score / 10) {
case 10, 9 -> 'A';
case 8 -> 'B';
case 7 -> 'C';
case 6 -> 'D';
default -> 'F';
};
}
public static Band band(int score) {
return switch (letter(score)) {
case 'A' -> Band.DISTINCTION;
case 'B' -> Band.MERIT;
case 'C', 'D' -> Band.PASS;
default -> Band.FAIL;
};
}
public static String report(int score) {
Band band = band(score);
return switch (band) {
case DISTINCTION -> "Distinction. Offer the honours seminar.";
case MERIT, PASS -> {
int target = band == Band.MERIT ? 90 : 80;
int gap = target - score;
String next = band == Band.MERIT ? "distinction" : "merit";
yield band + ". " + gap + " more mark" + (gap == 1 ? "" : "s") + " for " + next + ".";
}
case FAIL -> "Fail. Book a resit.";
};
}
public static int finalScore(Submission submission) {
return switch (submission) {
case Exam e -> e.score();
case Coursework c when c.marks().isEmpty() -> 0;
case Coursework c -> (int) Math.round(
c.marks().stream().mapToInt(Integer::intValue).average().orElse(0));
case Project p when p.late() -> Math.max(0, p.score() - 10);
case Project p -> p.score();
case Absent a -> 0;
};
}
public static String describe(Object raw) {
return switch (raw) {
case null -> "no value";
case Integer i when i < 0 || i > 100 -> "out of range: " + i;
case Integer i -> "score " + i + " (" + letter(i) + ")";
case String s when s.isBlank() -> "blank";
case String s -> describe(parse(s));
case Submission sub -> "submission worth " + finalScore(sub);
default -> "unsupported: " + raw.getClass().getSimpleName();
};
}
private static Object parse(String text) {
try {
return Integer.valueOf(text.trim());
} catch (NumberFormatException e) {
return null;
}
}
}
Step 6: A Main that exercises every arm
src/main/java/com/example/grades/Main.java
package com.example.grades;
import com.example.grades.Submission.Absent;
import com.example.grades.Submission.Coursework;
import com.example.grades.Submission.Exam;
import com.example.grades.Submission.Project;
import java.util.List;
public class Main {
public static void main(String[] args) {
for (Term term : Term.values()) {
System.out.println(term + " runs " + Grader.teachingWeeks(term) + " weeks");
}
System.out.println();
List<Submission> inbox = List.of(
new Exam(91),
new Coursework(List.of(78, 84, 80)),
new Project("Parcel router", 72, true),
new Project("Library kiosk", 72, false),
new Absent("illness"));
for (Submission s : inbox) {
int score = Grader.finalScore(s);
System.out.printf("%-52s -> %3d %s%n", s, score, Grader.report(score));
}
System.out.println();
for (Object raw : new Object[] {88, 140, " 67 ", "abc", "", null, 3.5}) {
System.out.println("describe(" + raw + ") = " + Grader.describe(raw));
}
}
}
Run it and read the output
mkdir -p out
javac -Xlint:all -d out $(find src -name '*.java')
java -cp out com.example.grades.Main
The compile prints one warning (the fall-through in the demo file, which is the point of that file) and nothing else. The run prints the report you saw at the top:
AUTUMN runs 12 weeks
SPRING runs 12 weeks
SUMMER runs 8 weeks
Exam[score=91] -> 91 Distinction. Offer the honours seminar.
Coursework[marks=[78, 84, 80]] -> 81 MERIT. 9 more marks for distinction.
Project[title=Parcel router, score=72, late=true] -> 62 PASS. 18 more marks for merit.
Project[title=Library kiosk, score=72, late=false] -> 72 PASS. 8 more marks for merit.
Absent[reason=illness] -> 0 Fail. Book a resit.
describe(88) = score 88 (B)
describe(140) = out of range: 140
describe( 67 ) = score 67 (D)
describe(abc) = no value
describe() = blank
describe(null) = no value
describe(3.5) = unsupported: Double
Worth reading line by line. The late project scored 72 but the guard knocked off ten, giving 62 and a PASS; the on-time project with the same 72 stays at 72. The coursework average of 78, 84 and 80 is 80.67, rounded to 81. describe(" 67 ") trims, parses and recurses to score 67 (D). describe("abc") prints no value, because parse returned null and case null took it. And 3.5 falls through to default.
Pin the behaviour with 39 checks
I couldn't pull JUnit from Maven Central in my environment, so these are plain Java: a check method that compares and counts. They run the same assertions you'd write in JUnit 5.
src/test/java/com/example/grades/Checks.java
package com.example.grades;
import com.example.grades.Submission.Absent;
import com.example.grades.Submission.Coursework;
import com.example.grades.Submission.Exam;
import com.example.grades.Submission.Project;
import java.util.ArrayList;
import java.util.List;
public class Checks {
private static int passed;
private static int failed;
private static void check(String name, Object expected, Object actual) {
if (expected.equals(actual)) {
passed++;
System.out.println("ok " + name);
} else {
failed++;
System.out.println("FAIL " + name + ": expected " + expected + " but got " + actual);
}
}
private static void checkThrows(String name, Class<? extends Throwable> type, Runnable code) {
try {
code.run();
failed++;
System.out.println("FAIL " + name + ": nothing thrown");
} catch (Throwable t) {
check(name, type, t.getClass());
}
}
public static void main(String[] args) {
check("autumn weeks", 12, Grader.teachingWeeks(Term.AUTUMN));
check("spring weeks", 12, Grader.teachingWeeks(Term.SPRING));
check("summer weeks", 8, Grader.teachingWeeks(Term.SUMMER));
check("100 is A", 'A', Grader.letter(100));
check("90 is A", 'A', Grader.letter(90));
check("89 is B", 'B', Grader.letter(89));
check("85 is B (no fall-through)", 'B', Grader.letter(85));
check("79 is C", 'C', Grader.letter(79));
check("60 is D", 'D', Grader.letter(60));
check("59 is F", 'F', Grader.letter(59));
check("0 is F", 'F', Grader.letter(0));
checkThrows("101 rejected", IllegalArgumentException.class, () -> Grader.letter(101));
checkThrows("-1 rejected", IllegalArgumentException.class, () -> Grader.letter(-1));
check("band of 95", Band.DISTINCTION, Grader.band(95));
check("band of 85", Band.MERIT, Grader.band(85));
check("band of 65", Band.PASS, Grader.band(65));
check("band of 12", Band.FAIL, Grader.band(12));
check("report distinction", "Distinction. Offer the honours seminar.", Grader.report(91));
check("report merit", "MERIT. 9 more marks for distinction.", Grader.report(81));
check("report singular mark", "MERIT. 1 more mark for distinction.", Grader.report(89));
check("report pass", "PASS. 8 more marks for merit.", Grader.report(72));
check("report fail", "Fail. Book a resit.", Grader.report(40));
check("exam score", 91, Grader.finalScore(new Exam(91)));
check("coursework average rounds", 81, Grader.finalScore(new Coursework(List.of(78, 84, 80))));
check("empty coursework", 0, Grader.finalScore(new Coursework(List.of())));
check("late project loses 10", 62, Grader.finalScore(new Project("p", 72, true)));
check("late project floors at 0", 0, Grader.finalScore(new Project("p", 4, true)));
check("on-time project", 72, Grader.finalScore(new Project("p", 72, false)));
check("absent", 0, Grader.finalScore(new Absent("illness")));
List<Integer> marks = new ArrayList<>(List.of(50, 60));
Coursework cw = new Coursework(marks);
marks.add(100);
check("coursework copies its list", 55, Grader.finalScore(cw));
check("describe null", "no value", Grader.describe(null));
check("describe int", "score 88 (B)", Grader.describe(88));
check("describe big int", "out of range: 140", Grader.describe(140));
check("describe negative", "out of range: -3", Grader.describe(-3));
check("describe numeric string", "score 67 (D)", Grader.describe(" 67 "));
check("describe bad string", "no value", Grader.describe("abc"));
check("describe blank", "blank", Grader.describe(" "));
check("describe submission", "submission worth 91", Grader.describe(new Exam(91)));
check("describe other type", "unsupported: Double", Grader.describe(3.5));
System.out.println();
System.out.println(passed + " passed, " + failed + " failed");
if (failed > 0) {
System.exit(1);
}
}
}
java -cp out com.example.grades.Checks
ok autumn weeks
ok spring weeks
ok summer weeks
ok 100 is A
ok 90 is A
ok 89 is B
ok 85 is B (no fall-through)
ok 79 is C
ok 60 is D
ok 59 is F
ok 0 is F
ok 101 rejected
ok -1 rejected
ok band of 95
ok band of 85
ok band of 65
ok band of 12
ok report distinction
ok report merit
ok report singular mark
ok report pass
ok report fail
ok exam score
ok coursework average rounds
ok empty coursework
ok late project loses 10
ok late project floors at 0
ok on-time project
ok absent
ok coursework copies its list
ok describe null
ok describe int
ok describe big int
ok describe negative
ok describe numeric string
ok describe bad string
ok describe blank
ok describe submission
ok describe other type
39 passed, 0 failed
To move these to JUnit 5, turn each check(...) into assertEquals(expected, actual) inside an @Test method, and each checkThrows into assertThrows. I haven't run that conversion here. A good next read is the series' JUnit tutorial once it's up: How to Write Unit Tests with JUnit 5.
What javac does with a switch
Three things happen that are worth knowing, because they explain every error in the next section.
Exhaustiveness. A switch expression must produce a value for every possible input. For an enum the compiler lists the constants; for a sealed type it lists the permitted subtypes; for anything else (int, char, String, Object) you need a default. You can drop default for enums and sealed types, and the compiler becomes your reviewer.
Dominance. Pattern labels are tested top to bottom, and the compiler rejects a label that an earlier label already covers completely. That's why Coursework c has to follow Coursework c when ....
No fall-through in arrow form. Each arrow arm is its own body: an expression, a block, or a throw. Under the hood, a switch on an enum is compiled against a synthetic lookup (that's the Grader$1.class file in out/), and a pattern switch is compiled into a dynamic call that picks the matching branch at runtime. You never write either; you only need to know the cost isn't a chain of instanceof tests at your call site.
Two neighbours worth a mention. instanceof got the same binding trick in Java 16, which is covered in How to Use instanceof Pattern Matching. And case Exam e can be taken apart further, as case Exam(int score), using record patterns, covered in How to Use Record Patterns. I kept this tutorial to type patterns so each idea stands on its own.
Seven mistakes, on purpose
Each of these is a real change I made to the finished project, with the exact compiler output.
1. A new enum constant. Add WINTER to Term and recompile.
src/main/java/com/example/grades/Grader.java:13: error: the switch expression does not cover all possible input values
return switch (term) {
^
1 error
The message doesn't name the missing constant, which surprised me. Look at every switch over Term. That's the feature working: the compiler found the spot you'd have missed.
2. A dominated label. Move the general case Coursework c above the guarded one.
src/main/java/com/example/grades/Grader.java:60: error: this case label is dominated by a preceding case label
case Coursework c when c.marks().isEmpty() -> 0;
^
1 error
The error points at the label that's now unreachable. Reorder so specific cases come first.
3. A block arm with no yield. Delete the word yield from report.
src/main/java/com/example/grades/Grader.java:49: error: not a statement
band + ". " + gap + " more mark" + (gap == 1 ? "" : "s") + " for " + next + ".";
^
1 error
not a statement is the compiler saying you wrote a bare value inside a block. Put yield back.
4. Mixing arrows and colons. Change case 8 -> 'B'; to case 8: return 'B'; in letter.
src/main/java/com/example/grades/Grader.java:25: error: different case kinds used in the switch
case 8: return 'B';
^
src/main/java/com/example/grades/Grader.java:25: error: attempt to return out of a switch expression
case 8: return 'B';
^
2 errors
A switch is either all arrows or all colons. And you can't return out of a switch expression, so even the second error is correct.
5. A missing default. Delete the default -> 'F'; line from letter.
src/main/java/com/example/grades/Grader.java:23: error: the switch expression does not cover all possible input values
return switch (score / 10) {
^
1 error
Same message as the enum case, different cause. Integers can't be listed out, so the compiler wants the catch-all.
6. A new sealed subtype. Add record Withdrawn() implements Submission { } to Submission.
src/main/java/com/example/grades/Grader.java:56: error: the switch expression does not cover all possible input values
return switch (submission) {
^
1 error
Exactly what you wanted. finalScore needs a Withdrawn arm.
7. A duplicate label. Change case 7 -> 'C'; to case 8 -> 'C';.
src/main/java/com/example/grades/Grader.java:26: error: duplicate case label
case 8 -> 'C';
^
1 error
And two runtime and version snags, because they bite later rather than at compile time.
A null selector with no case null still throws. This is a plain switch on a String that is null:
Exception in thread "main" java.lang.NullPointerException: Cannot invoke "String.hashCode()" because "<local3>" is null
at NullDemo.main(NullDemo.java:4)
The <local3> is the missing variable name; compile with -g to get the real one. Fix: add case null ->, or check for null before the switch. There's also an existing article on exactly this at Java switch NullPointerException.
And compiling pattern switch for an older release fails like this (--release 17):
src/main/java/com/example/grades/Grader.java:57: error: patterns in switch statements are not supported in -source 17
case Exam e -> e.score();
^
(use -source 21 or higher to enable patterns in switch statements)
1 error
If you see patterns in switch statements are not supported, you're on an older language level than your JDK. In the IDE, that's the project's Language level (IntelliJ) or Compiler compliance level (Eclipse). In a build tool, the release setting.
Before you copy this into a codebase
- Avoid
defaulton enums and sealed types you own. The missing-case error is the main reason to use the new form at all. Keepdefaultforint,String,charandObject. - Guard order is logic. Put narrow
whencases first, the general one last, and read the result top to bottom as if it were anifchain, because that's what it is. - Old
switchstatements can stay. You don't need to rewrite working code. I'd convert one the next time I touch it, particularly if it has fall-through you can't explain. - Don't use
switchas a visitor replacement everywhere. If behaviour belongs to the type, put it on the type. The pattern switch is best when the logic is about a closed set of data shapes, asfinalScoreis. yieldinside old-style colon switch expressions also works (case 1: yield 'x';), but I haven't met a reason to prefer it.- Newer JDKs. Java 22 made unnamed patterns (
_) final; on 21 they're a preview feature. I didn't run this on 22 or 25.
What to remember
- Put
return switchorvar x = switchand use arrows; the old colon form is for existing code. - Group constants in one label:
case A, B ->. - Drop
defaultfor enums and sealed types; keep it for everything else. - Use
yieldto return from a block arm. - Put narrow
whenguards before general cases. - Add
case nullwhennullis a legal input.
Switch questions that come up a lot
What's the difference between a switch statement and a switch expression in Java? An expression yields a value and must cover every input; a statement runs code and may skip cases. Both can use arrows. Expressions arrived in Java 14.
Can a switch expression use pattern matching? Yes, from Java 21. The labels become types with optional when guards, and the compiler checks exhaustiveness for sealed types.
How do I handle null in a Java switch? Add case null -> (Java 21). Without it, a null selector throws NullPointerException. You can combine it as case null, default -> to treat it like everything else.
Do I need default in a switch expression? Only when the compiler can't list every possibility. Enums and sealed types: no. int, String, Object: yes.
References
- JEP 361: Switch Expressions, observed 2026-10-26
- JEP 441: Pattern Matching for switch, observed 2026-10-26
- JEP 440: Record Patterns
- Java Language Specification, chapter 14.11, switch statements and expressions