Skip to content

How to Use Switch Expressions and Pattern Matching in Java

Build a student-grade report in Java 21 with arrow switches, yield, guarded patterns and case null. Full code, 39 checks, and seven compile errors forced.

how-to-tutorials modern-java core-java
Gopi Gorantala
Reading Progress

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 default on enums and sealed types you own. The missing-case error is the main reason to use the new form at all. Keep default for int, String, char and Object.
  • Guard order is logic. Put narrow when cases first, the general one last, and read the result top to bottom as if it were an if chain, because that's what it is.
  • Old switch statements 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 switch as 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, as finalScore is.
  • yield inside 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 switch or var x = switch and use arrows; the old colon form is for existing code.
  • Group constants in one label: case A, B ->.
  • Drop default for enums and sealed types; keep it for everything else.
  • Use yield to return from a block arm.
  • Put narrow when guards before general cases.
  • Add case null when null is 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

how-to-tutorialsmodern-javacore-java

Staff engineer architecting Kafka and Flink platforms for banking, where a dropped event is a reconciliation break, not a retry. Through javahandbook.com, I share production-tested guidance on building reliable event-driven systems.

Comments