Learn Java

Lesson 30Phase 3: Abstraction and design

Sealed types and pattern matching

Closing a hierarchy with sealed and permits, then taking values apart with instanceof patterns, switch patterns and record patterns.

By Updated 50 min

  • sealed
  • permits
  • non-sealed
  • pattern matching
  • instanceof pattern
  • switch pattern

Records gave you a tidy way to hold data: a circle is its radius, a rectangle is its width and height. As soon as a program holds several kinds of record, it keeps asking the same question: which kind is this one, and what is inside it? This lesson gives you Java's tools for that question. You will test a value's type and unpack its parts in a single step, and you will close a family of types so the compiler can check that you handled every member.

By the end of this lesson
  • Replace an instanceof check and a cast with one instanceof pattern, and say where its variable can be used.
  • Write a switch that picks a case by type, with when guards and case null.
  • Unpack a record in one pattern, including nested record patterns and var.
  • Close a family of types with sealed and permits, and mark each member final, sealed or non-sealed.
  • Explain why a switch over a sealed type needs no default, and read javac's error when a case is missing.
  • Choose between a method in every class and a switch over sealed records.
From before
This page leans on records: declaring one, reading a component through its accessor such as c.radius(), and what its toString prints. From polymorphism you need instanceof and the downcast (Dog) a. You also use interfaces, final classes from static and final, and the switch statement from making decisions.

The old way: check, then cast#

Picture a small drawing app with three kinds of shape. A circle is a radius, a rectangle is a width and a height, and a triangle is a base and a height. That is plain data, so each kind is a record. The rectangle is called Rect to keep the lines short.

An interface called Shape ties the three together, so that one array can hold them all. Shapehas no methods yet. It only says “all of these are shapes”.

Now the app needs the area of any shape. With what you learned in polymorphism, you check the kind with instanceof, then cast to reach the record's accessors.

Main.java
1public class Main {
2 public static void main(String[] args) {
3 Shape[] shapes = { new Circle(1.0), new Rect(3.0, 4.0), new Triangle(6.0, 2.0) };
4 for (Shape shape : shapes) {
5 System.out.println(area(shape));
6 }
7 }
8
9 static double area(Shape shape) {
10 if (shape instanceof Circle) {
11 Circle c = (Circle) shape;
12 return Math.PI * c.radius() * c.radius();
13 } else if (shape instanceof Rect) {
14 Rect r = (Rect) shape;
15 return r.width() * r.height();
16 } else if (shape instanceof Triangle) {
17 Triangle t = (Triangle) shape;
18 return 0.5 * t.base() * t.height();
19 }
20 return 0;
21 }
22}
23
24interface Shape {}
25record Circle(double radius) implements Shape {}
26record Rect(double width, double height) implements Shape {}
27record Triangle(double base, double height) implements Shape {}
Prints
›3.141592653589793
›12.0
›6.0

It prints 3.141592653589793, 12.0 and 6.0. It works, but read one branch out loud: “if shape is a Circle, declare a Circle called c, and set it to shape cast to Circle.” That is the word Circle three times for one idea.

The repetition also hides a trap. The test and the cast are separate lines, and Java does not connect them. A copy-paste slip such as Rect r = (Rect) shape; inside the triangle branch compiles without complaint, then stops the program with a ClassCastException the first time a triangle arrives. And the last line, return 0;, is there only because the compiler cannot tell whether the three checks cover every possible shape.

The instanceof pattern: test and name in one step#

Since Java 16 you can put a variable name straight after the type in an instanceof test: shape instanceof Circle c. Read it as “is shape a Circle? If so, call it c”. When the test passes, Java creates c, already typed as Circle and already holding the shape. When the test fails, there is no c at all. You never write the cast yourself, so you cannot cast to the wrong type.

This is your first pattern: a description of what a value should look like, plus names for the parts you want if it does. Checking a value against a pattern is called pattern matching. Circle c is a type pattern, a type to test for and a name to use, and c is its pattern variable.

Main.java
1public class Main {
2 public static void main(String[] args) {
3 Shape[] shapes = { new Circle(1.0), new Rect(3.0, 4.0), new Triangle(6.0, 2.0) };
4 for (Shape shape : shapes) {
5 System.out.println(area(shape));
6 }
7 }
8
9 static double area(Shape shape) {
10 if (shape instanceof Circle c) {
11 return Math.PI * c.radius() * c.radius();
12 } else if (shape instanceof Rect r) {
13 return r.width() * r.height();
14 } else if (shape instanceof Triangle t) {
15 return 0.5 * t.base() * t.height();
16 }
17 return 0;
18 }
19}
20
21interface Shape {}
22record Circle(double radius) implements Shape {}
23record Rect(double width, double height) implements Shape {}
24record Triangle(double base, double height) implements Shape {}
Prints
›3.141592653589793
›12.0
›6.0

Same three areas, and each branch now names its type once. Step through it and watch the trace: c, r and t each appear only inside the branch whose test passed.

Where the pattern variable exists

A pattern variable exists only where Java can be sure the test passed. Java works that out from the logic around the test, and these are the cases you will meet most:

  • After &&, it exists. In shape instanceof Circle c && c.radius() > 10 the right side only runs when the left side was true, so c is ready to use.
  • After ||, it does not. In shape instanceof Circle c || c.radius() > 10 the right side runs exactly when the shape is not a circle, so there is no circle to ask. javac stops with cannot find symbol.
  • Below a negated test that returns, it exists. Write if (!(shape instanceof Rect r)) return ...; and every line after that if can only be reached by a rectangle, so r works for the rest of the method.
Main.java
1public class Main {
2 public static void main(String[] args) {
3 Shape[] shapes = { new Circle(12.0), new Circle(0.5), new Rect(3.0, 4.0) };
4 for (Shape shape : shapes) {
5 if (shape instanceof Circle c && c.radius() > 10) {
6 System.out.println("Big circle: " + c);
7 }
8 }
9
10 System.out.println(squareCheck(new Rect(5.0, 5.0)));
11 System.out.println(squareCheck(new Rect(2.0, 5.0)));
12 System.out.println(squareCheck(new Circle(1.0)));
13 }
14
15 static String squareCheck(Shape shape) {
16 if (!(shape instanceof Rect r)) {
17 return "not a rectangle";
18 }
19 if (r.width() != r.height()) {
20 return "a rectangle, but not a square";
21 }
22 return "a square with side " + r.width();
23 }
24}
25
26interface Shape {}
27record Circle(double radius) implements Shape {}
28record Rect(double width, double height) implements Shape {}
Prints
›Big circle: Circle[radius=12.0]
›a square with side 5.0
›a rectangle, but not a square
›not a rectangle

The loop prints a single line, Big circle: Circle[radius=12.0]. The small circle fails the size check and the rectangle fails the type check. Then squareCheck prints a square with side 5.0, a rectangle, but not a square and not a rectangle.

Look at how squareCheck begins. It deals with the odd case first and returns, so the rest of the method reads as the normal path, with r ready and no extra level of indentation.

Quick check
Each line assumes a variable Shape shape and the records from this page. Which one compiles?

Patterns in a switch#

A quick recap: the arrow form of switch

You met the arrow form and switch expressions in making decisions: after an arrow only that case's code runs, with no fall-through and so no break, and a switch can produce a value. This lesson puts patterns into that form. Here it is on a plain number once more.

Main.java
1public class Main {
2 public static void main(String[] args) {
3 int sides = 4;
4
5 String name = switch (sides) {
6 case 3 -> "triangle";
7 case 4 -> "rectangle";
8 case 5 -> "pentagon";
9 default -> "some other polygon";
10 };
11
12 System.out.println(name);
13 }
14}
Prints
›rectangle

It prints rectangle. One rule from that lesson matters again below: a switch expression must produce a value whatever comes in. An int can hold over four billion different values and the cases cover three of them, so the default line is required.

Two arrows, two meanings
The arrow in a switch case has nothing to do with the arrow in a lambda from the lambdas lesson. Java reuses the symbol, and where it appears tells you which one you are reading.

Type patterns in case labels

A case label can hold a type pattern, the same Circle c you wrote after instanceof. Java tries the cases from top to bottom and runs the first one that matches. Here is the area method again:

Main.java
1public class Main {
2 public static void main(String[] args) {
3 Shape[] shapes = { new Circle(1.0), new Rect(3.0, 4.0), new Triangle(6.0, 2.0) };
4 for (Shape shape : shapes) {
5 System.out.println(area(shape));
6 }
7 }
8
9 static double area(Shape shape) {
10 return switch (shape) {
11 case Circle c -> Math.PI * c.radius() * c.radius();
12 case Rect r -> r.width() * r.height();
13 case Triangle t -> 0.5 * t.base() * t.height();
14 default -> 0;
15 };
16 }
17}
18
19interface Shape {}
20record Circle(double radius) implements Shape {}
21record Rect(double width, double height) implements Shape {}
22record Triangle(double base, double height) implements Shape {}
Prints
›3.141592653589793
›12.0
›6.0

The same three areas come out. The method now reads like a table: a circle uses this formula, a rectangle that one, a triangle the third.

Why is the default there? Shape is an ordinary interface, so any class in any file may implement it. Tomorrow someone could write record Hexagon(double side) implements Shape {}, and this switch has no case for it. A switch expression must produce a value for every possible shape, and with an open interface that takes a catch-all case, which is what default is. Delete it and javac refuses with the switch expression does not cover all possible input values. Hold on to that: two sections from now, a new keyword makes the default unnecessary.

Guards: case ... when

Sometimes the type alone is not enough. A rectangle whose width equals its height is a square, and the app should say so. You can add a condition to a case with the word when. The condition is called a guard.

Main.java
1public class Main {
2 public static void main(String[] args) {
3 Shape[] shapes = { new Rect(2.0, 2.0), new Rect(2.0, 5.0), new Circle(0.0), new Circle(3.0) };
4 for (Shape shape : shapes) {
5 System.out.println(describe(shape));
6 }
7 }
8
9 static String describe(Shape shape) {
10 return switch (shape) {
11 case Circle c when c.radius() == 0 -> "a dot";
12 case Circle c -> "a circle of radius " + c.radius();
13 case Rect r when r.width() == r.height() -> "a square of side " + r.width();
14 case Rect r -> "a " + r.width() + " by " + r.height() + " rectangle";
15 default -> "some other shape";
16 };
17 }
18}
19
20interface Shape {}
21record Circle(double radius) implements Shape {}
22record Rect(double width, double height) implements Shape {}
Prints
›a square of side 2.0
›a 2.0 by 5.0 rectangle
›a dot
›a circle of radius 3.0

case Rect r when r.width() == r.height() matches when the shape is a rectangle and the condition is true. The guard can already use r. If the shape is a rectangle but the condition is false, Java carries on to the next case, case Rect r, which takes every other rectangle. The circles work the same way, so the four lines are a square of side 2.0, a 2.0 by 5.0 rectangle, a dot and a circle of radius 3.0.

Order matters. Put a guarded case above the plain case for the same type. If case Rect r came first, it would take every rectangle, squares included, and the square case could never run. javac rejects that with this case label is dominated by a preceding case label. A case is dominated when a case above it already matches everything it could match. The habit that avoids it: specific cases first, general ones last.

Predict the outputMain.java
1public class Main {
2 public static void main(String[] args) {
3 Shape[] shapes = { new Rect(20.0, 20.0), new Rect(5.0, 5.0), new Circle(2.0) };
4 for (Shape shape : shapes) {
5 System.out.println(sizeOf(shape));
6 }
7 }
8
9 static String sizeOf(Shape shape) {
10 return switch (shape) {
11 case Rect r when r.width() > 10 -> "wide";
12 case Rect r when r.width() == r.height() -> "square";
13 case Circle c when c.radius() < 1 -> "tiny";
14 case Rect r -> "plain rectangle";
15 default -> "other";
16 };
17 }
18}
19
20interface Shape {}
21record Circle(double radius) implements Shape {}
22record Rect(double width, double height) implements Shape {}

The first rectangle is both wide and square. Decide which case takes it, then write the three lines this program prints.

case null

A Shape variable can also hold null, the value that means “no object”. A switch on null stops the program with a NullPointerException, and a default does not catch it. If null is a value you expect, give it a case of its own:

Main.java
1public class Main {
2 public static void main(String[] args) {
3 Shape picked = null;
4
5 String message = switch (picked) {
6 case null -> "nothing picked yet";
7 case Circle c -> "a circle";
8 case Rect r -> "a rectangle";
9 default -> "another shape";
10 };
11
12 System.out.println(message);
13 }
14}
15
16interface Shape {}
17record Circle(double radius) implements Shape {}
18record Rect(double width, double height) implements Shape {}
Prints
›nothing picked yet

This prints nothing picked yet. When null and every unmatched shape deserve the same answer, put both on one line: case null, default -> "something else";.

Key idea

A switch with patterns tries its cases from top to bottom and runs the first one that matches. A guard narrows a case; when the guard is false, the search carries on below.

Record patterns: unpack the parts#

Look at what the area cases do with their variable: case Rect r -> r.width() * r.height(). The only reason to name the rectangle r is to call its two accessors. A record pattern skips that step. It checks the type and lifts the components straight out into variables of their own:

Main.java
1public class Main {
2 public static void main(String[] args) {
3 Shape[] shapes = { new Circle(1.0), new Rect(3.0, 4.0), new Triangle(6.0, 2.0) };
4 for (Shape shape : shapes) {
5 System.out.println(area(shape));
6 }
7 }
8
9 static double area(Shape shape) {
10 return switch (shape) {
11 case Circle(double r) -> Math.PI * r * r;
12 case Rect(double w, double h) -> w * h;
13 case Triangle(double b, double h) -> 0.5 * b * h;
14 default -> 0;
15 };
16 }
17}
18
19interface Shape {}
20record Circle(double radius) implements Shape {}
21record Rect(double width, double height) implements Shape {}
22record Triangle(double base, double height) implements Shape {}
Prints
›3.141592653589793
›12.0
›6.0

Read Rect(double w, double h)as “a Rect, whose width I will call w and whose height I will call h”. It is new Rect(3.0, 4.0)run backwards. Construction puts values in, in the order of the record's header, and the record pattern takes them out in the same order. Java calls the accessors for you.

The names are yours to choose, which is why the triangle's base can be b here. The order and the types must fit the record.

var: let Java fill in the types

Writing double for every component gets repetitive. Inside a pattern you may write var instead, and Java uses the component's own type: Rect(var w, var h) still gives you two double variables. (var works for ordinary local variables too, which you will meet in modern Java syntax.)

A record pattern can carry a guard, and it works after instanceof as well:

Main.java
1public class Main {
2 public static void main(String[] args) {
3 Shape[] shapes = { new Circle(1.0), new Rect(2.0, 2.0), new Rect(2.0, 5.0), new Triangle(6.0, 2.0) };
4 for (Shape shape : shapes) {
5 System.out.println(describe(shape));
6 }
7
8 Shape picked = shapes[1];
9 if (picked instanceof Rect(var w, var h) && w == h) {
10 System.out.println("The second shape is a square of side " + w);
11 }
12 }
13
14 static String describe(Shape shape) {
15 return switch (shape) {
16 case Circle(var r) -> "a circle of radius " + r;
17 case Rect(var w, var h) when w == h -> "a square of side " + w;
18 case Rect(var w, var h) -> "a " + w + " by " + h + " rectangle";
19 case Triangle(var b, var h) -> "a triangle, base " + b + " and height " + h;
20 default -> "some other shape";
21 };
22 }
23}
24
25interface Shape {}
26record Circle(double radius) implements Shape {}
27record Rect(double width, double height) implements Shape {}
28record Triangle(double base, double height) implements Shape {}
Prints
›a circle of radius 1.0
›a square of side 2.0
›a 2.0 by 5.0 rectangle
›a triangle, base 6.0 and height 2.0
›The second shape is a square of side 2.0

The first four lines come from describe, where the guard when w == h uses the unpacked width and height directly. The last line comes from the if: picked instanceof Rect(var w, var h) && w == h tests the type, unpacks the rectangle and compares its sides in one condition, and prints The second shape is a square of side 2.0.

ExerciseMain.java
Finish isSquare so it returns true only for a Rect whose width equals its height. Use instanceof with a record pattern, all in one return line.

Nested record patterns

A record can hold another record. In the drawing app a sticker has a shape and a color: record Sticker(Shape shape, String color) {}. A record pattern can hold another record pattern in a component's place, so one case can unpack the sticker and the shape inside it.

Main.java
1public class Main {
2 public static void main(String[] args) {
3 Sticker[] stickers = {
4 new Sticker(new Circle(2.0), "red"),
5 new Sticker(new Rect(1.0, 1.0), "blue"),
6 new Sticker(new Triangle(3.0, 4.0), "green")
7 };
8 for (Sticker sticker : stickers) {
9 System.out.println(label(sticker));
10 }
11 }
12
13 static String label(Sticker sticker) {
14 return switch (sticker) {
15 case Sticker(Circle(var r), var color) -> "a " + color + " circle, radius " + r;
16 case Sticker(Rect(var w, var h), var color) when w == h -> "a " + color + " square";
17 case Sticker(var shape, var color) -> "a " + color + " " + shape;
18 };
19 }
20}
21
22interface Shape {}
23record Circle(double radius) implements Shape {}
24record Rect(double width, double height) implements Shape {}
25record Triangle(double base, double height) implements Shape {}
26record Sticker(Shape shape, String color) {}
Prints
›a red circle, radius 2.0
›a blue square
›a green Triangle[base=3.0, height=4.0]

Sticker(Circle(var r), var color)matches only a sticker whose shape is a circle, and it hands you the circle's radius and the sticker's color together. A nested pattern matches only if every part matches. If the inside does not fit, Java moves on to the next case. So the first two stickers print a red circle, radius 2.0 and a blue square.

The last case, Sticker(var shape, var color), takes any sticker at all, because var accepts whatever is in its slot. That is why this switch needs no default. The green triangle lands there and prints the shape through the record's toString: a green Triangle[base=3.0, height=4.0].

Predict the outputMain.java
1public class Main {
2 public static void main(String[] args) {
3 Sticker[] stickers = {
4 new Sticker(new Rect(3.0, 3.0), "red"),
5 new Sticker(new Circle(1.0), "red"),
6 new Sticker(new Rect(2.0, 6.0), "green")
7 };
8 for (Sticker sticker : stickers) {
9 System.out.println(price(sticker));
10 }
11 }
12
13 static int price(Sticker sticker) {
14 return switch (sticker) {
15 case Sticker(Rect(var w, var h), var color) when w == h -> 3;
16 case Sticker(var shape, var color) when color.equals("red") -> 2;
17 case Sticker(var shape, var color) -> 1;
18 };
19 }
20}
21
22interface Shape {}
23record Circle(double radius) implements Shape {}
24record Rect(double width, double height) implements Shape {}
25record Sticker(Shape shape, String color) {}

The first sticker is red and square, so two cases could take it. Pick the output.

Sealed types: closing the family#

Every switch over Shape so far has needed a default, because Shape is open: any class anywhere may implement it. But the drawing app has exactly three shapes, and you would like Java to know that too. Since Java 17 you can say so:

Shape.java
read along
1sealed interface Shape permits Circle, Rect, Triangle {}
2
3record Circle(double radius) implements Shape {}
4record Rect(double width, double height) implements Shape {}
5record Triangle(double base, double height) implements Shape {}

sealed means the family is closed, and permits is its guest list: only the types named there may implement Shape. Try to add a fourth shape that is not on the list, such as record Star(int points) implements Shape {}, and javac stops you with class is not allowed to extend sealed class: Shape (as it is not listed in its 'permits' clause). The message says “class” and “extend” even for an interface and a record; it uses the general words.

Every member says what comes below it

A sealed type lists its direct members. Each member must then say whether the family goes on below it, with one of three modifiers:

Modifier on the memberWhat it meansWho may extend the member
finalThe family stops here.Nobody.
sealedThe member is closed too, with a permits list of its own.Only the classes on its list.
non-sealedThe member opens the family up again.Any class at all.

Leave the modifier off and javac complains: sealed, non-sealed or final modifiers expected. The shapes above needed none of this, because a record is automatically final. Nothing can extend a record.

Sealed classes

sealed works on classes too. Here an abstract class closes its family, and its three members use one modifier each:

Main.java
1public class Main {
2 public static void main(String[] args) {
3 Vehicle[] fleet = { new Car(), new Truck(), new FireTruck(), new Bike(), new RacingBike() };
4 for (Vehicle v : fleet) {
5 System.out.println(describe(v));
6 }
7 }
8
9 static String describe(Vehicle v) {
10 return switch (v) {
11 case Car c -> "a car with 4 wheels";
12 case FireTruck f -> "a fire truck with a ladder";
13 case Truck t -> "a truck with 6 wheels";
14 case Bike b -> "a bike with 2 wheels";
15 };
16 }
17}
18
19abstract sealed class Vehicle permits Car, Truck, Bike {}
20
21final class Car extends Vehicle {}
22
23sealed class Truck extends Vehicle permits FireTruck {}
24
25final class FireTruck extends Truck {}
26
27non-sealed class Bike extends Vehicle {}
28
29class RacingBike extends Bike {}
Prints
›a car with 4 wheels
›a truck with 6 wheels
›a fire truck with a ladder
›a bike with 2 wheels
›a bike with 2 wheels

Car is final, so that branch ends there. Truck is sealed with a list of its own, so only FireTruck may extend it. Bike is non-sealed, so any class may extend it, and RacingBike does without appearing on any list.

The switch still handles every vehicle. case Truck t takes plain trucks, because the fire truck was already taken by the case above it. case Bike b takes every bike, including kinds nobody has written yet. The output is a car with 4 wheels, a truck with 6 wheels, a fire truck with a ladder, then a bike with 2 wheels twice.

One more detail lets this switch skip the default: Vehicle is abstract, so no object is ever a plain Vehicle. If it were not abstract, the switch would need a case for Vehicle itself.

Where the members must live
The members of a sealed family must sit close to it: in the same package, or the same module in a larger app that uses modules. (Packages arrive in packages and imports.) In this course each program is one file, which is the simplest case. When every member is in the same file as the sealed type, you may even leave out permits and Java collects the list for you. Writing it out keeps the list in front of the reader.
Quick check
Given abstract sealed class Vehicle permits Car, Bus {}, which declaration of Bus does not compile?
Key idea

A sealed type names every direct member. Each member is final (nothing below it), sealed (its own short list) or non-sealed (open to anyone).

Exhaustiveness: the compiler checks every case#

Because Shape is sealed, the compiler knows that a shape can only be a circle, a rectangle or a triangle. A switch that handles those three handles every shape there can ever be, so the default can go:

Main.java
1public class Main {
2 public static void main(String[] args) {
3 Shape[] shapes = { new Circle(1.0), new Rect(3.0, 4.0), new Triangle(6.0, 2.0) };
4 for (Shape shape : shapes) {
5 System.out.println(area(shape));
6 }
7 }
8
9 static double area(Shape shape) {
10 return switch (shape) {
11 case Circle(var r) -> Math.PI * r * r;
12 case Rect(var w, var h) -> w * h;
13 case Triangle(var b, var h) -> 0.5 * b * h;
14 };
15 }
16}
17
18sealed interface Shape permits Circle, Rect, Triangle {}
19record Circle(double radius) implements Shape {}
20record Rect(double width, double height) implements Shape {}
21record Triangle(double base, double height) implements Shape {}
Prints
›3.141592653589793
›12.0
›6.0

A switch that handles every possible value is exhaustive. Java checks that every switch expression is exhaustive when it compiles your code, before anything runs. With an open interface you needed a catch-all such as default to pass that check. With a sealed one, listing every member is enough.

When you forget a case

Take the same program, delete the triangle case, and compile it with a real JDK:

Main.java
read along
1public class Main {
2 public static void main(String[] args) {
3 Shape[] shapes = { new Circle(1.0), new Rect(3.0, 4.0), new Triangle(6.0, 2.0) };
4 for (Shape shape : shapes) {
5 System.out.println(area(shape));
6 }
7 }
8
9 static double area(Shape shape) {
10 return switch (shape) {
11 case Circle(var r) -> Math.PI * r * r;
12 case Rect(var w, var h) -> w * h;
13 };
14 }
15}
16
17sealed interface Shape permits Circle, Rect, Triangle {}
18record Circle(double radius) implements Shape {}
19record Rect(double width, double height) implements Shape {}
20record Triangle(double base, double height) implements Shape {}
Terminal
read along
1$ javac Main.java
2Main.java:10: error: the switch expression does not cover all possible input values
3 return switch (shape) {
4 ^
51 error

Read it the way you read any compile error: the file and line (Main.java:10), the complaint, and a caret under the switch. The message does not say which kind is missing. You find it by comparing the cases with the permits list. You find out while compiling, at your desk, long before a user draws the first triangle.

The Run button is more forgiving than javac
The in-browser engine runs code without this compile-time check. Delete a case from a switch expression in the playground and the program still runs; it only stops, with a MatchException, when a value arrives that no case matches. A real JDK refuses to compile the program at all, and that is the behavior to expect from Java.

Adding a new member

The check earns its keep when the family grows. Say the app gets squares. You write record Square(double side) implements Shape {} and add Square to the permits list. Every switch over Shape that has no square case and no default now fails to compile, each with the error above. Together those errors are a complete to-do list of the places where squares need a decision.

Now imagine those switches had kept a default. Everything compiles, and every square quietly takes the default branch: an area of 0, a description of “some other shape”. Nothing tells you. That is why, for a sealed type, you list every member and leave default out.

Key idea

Over a sealed type, leave out default. Then adding a member turns every switch that forgot it into a compile error: a to-do list you can trust.

The fine print

  • A guarded case never counts. case Rect r when r.width() > 10 on its own does not cover rectangles, because its condition could be false, so you still need a case for the rest. Java does not try to reason about what the condition says.
  • The rule also covers switch statements that use patterns or case null. Such a switch must be exhaustive even when it produces no value, and javac then reports the switch statement does not cover all possible input values. An old-style switch on an int, a String or an enum, with no patterns, still does not have to be.
  • A chain of if and instanceof gets no such check at all, sealed type or not. Exhaustiveness checking comes only with a switch.

Try the rule for yourself below. Comment cases out, bring them back, add a Square to the family, and read javac's verdict. When the switch compiles, send a value through and see which case takes it.

Switch lab

Tap a case line to comment it out or bring it back. Then add a Square to the family and see what still compiles.

sealed interface Shape permits Circle, Rect, Triangle {}
String label = switch (shape) {
};

JAVAC: COMPILES

Every kind in the permits list has a case without a guard, so every possible Shape is handled.

Send a value through the switch

  1. 1case Circle cnot a Circle, so try the next case
  2. 2case Rect r when r.width() > 10a Rect, but r.width() > 10 is false (the width is 3.0), so try the next case
  3. 3case Rect ra Rect: this case wins
  4. label is "rectangle"
Quick check
Shape is sealed and permits Circle, Rect and Triangle. Which switch expression over a Shape compiles?
Put the lines in orderMain.java
Rebuild a program that prints square for a 4 by 4 rectangle. Watch the order of the two Rect cases.

Tap a line, then tap where it should go. Or use the arrows.

Two ways to give every shape an area#

Step back and compare all this with polymorphism. There, each class carried its own version of a method, and Java picked the right version at run time. The shapes can work that way too. The interface declares area(), and each record supplies its own:

Main.java
1public class Main {
2 public static void main(String[] args) {
3 Shape[] shapes = { new Circle(1.0), new Rect(3.0, 4.0), new Triangle(6.0, 2.0) };
4 for (Shape shape : shapes) {
5 System.out.println(shape.area());
6 }
7 }
8}
9
10sealed interface Shape permits Circle, Rect, Triangle {
11 double area();
12}
13
14record Circle(double radius) implements Shape {
15 public double area() {
16 return Math.PI * radius * radius;
17 }
18}
19
20record Rect(double width, double height) implements Shape {
21 public double area() {
22 return width * height;
23 }
24}
25
26record Triangle(double base, double height) implements Shape {
27 public double area() {
28 return 0.5 * base * height;
29 }
30}
Prints
›3.141592653589793
›12.0
›6.0

Same output, and no switch anywhere: the loop calls shape.area() and each record answers for itself. Neither version is wrong. They arrange the same pieces of code in two directions, which is easiest to see as a table.

areadescribedraw
Circlearea of a circlewords for a circlepixels for a circle
Rectarea of a rectanglewords for a rectanglepixels for a rectangle
Trianglearea of a trianglewords for a trianglepixels for a triangle
Every cell is a small piece of code: how one kind of shape does one job.

A method in every class cuts this table into rows. Each record holds its own row: every job, for one kind of shape. A switch cuts the table into columns. Each method holds one column: one job, for every kind of shape.

The column style has a name: data-oriented programming. In plain words, you keep the data as plain records, close the set of kinds with sealed, and write each operation as a method with a switch over that set. The records know nothing about drawing or describing; each operation knows how to handle every kind.

Each style makes a different change easy. With a method in every class, a new kind is one new class and nothing else changes, but a new job means editing every class. With switches, a new job is one new method and the records stay as they are, while a new kind means visiting every switch. The compiler makes that visit safe by naming each switch you must touch.

A method in every class
A switch over sealed records
Each class holds every job for its own kind.
Each method holds one job for every kind.
A new kind: write one class. Nothing else changes.
A new kind: add it to permits, then fix each switch javac names.
A new job: add a method to the interface and to every class.
A new job: write one method with one switch. The records stay as they are.
Anyone may add kinds, even code you never see.
The permits list fixes the kinds.
Fits when new kinds keep arriving and each one owns its behavior, like animals that speak or buttons that react to clicks.
Fits when the kinds are settled and the jobs keep coming, like shapes to measure, draw and save, or the moves in a board game.
Key idea

Records hold the data, sealed closes the set of kinds, and each switch holds one job. Pick this style when the kinds are settled and the jobs keep coming.

Quick check
A chat app has exactly four kinds of message, and that list rarely changes. Every month someone asks for a new job done on messages: a preview, a search, an export. Which design fits better?

Your turn: a ticket machine#

A train company sells three kinds of ticket, and each kind is a record holding the passenger's age. The family is sealed, so a switch over it needs no default. Write the pricing rule as one switch expression.

ExerciseMain.java
Finish priceOf. Adults pay 12, seniors pay 8 and children pay 6, but children under 3 travel free. Use a switch with type patterns and one guard, and no default.

Try it

Answer each one in your head or on paper first, then open the answer.

1
Rewrite this with an instanceof pattern: if (item instanceof Book) { Book b = (Book) item; System.out.println(b.title()); }
2
Why does javac reject a switch in which case Rect r comes before case Rect r when r.width() == r.height()?
3
What does this program print? Look closely at the middle element of the array.
4
A class is named in the permits list of a sealed class. Which three modifiers can it choose from, and what does each one allow below it?
5
A switch over the sealed Shape ends with a default. You add Square to the permits list. What does javac say, and what happens when a square reaches that switch?
6
What does the pattern Sticker(Rect(var w, var h), var color) match, and which variables does it give you?

Practice

Work through these on your own. Each one builds on this page.

1
Type the sealed Shape program from the exhaustiveness section into a real Java file, and give it a describe method like the one in the record patterns section, but with no default. Then add record Square(double side) implements Shape {} and put Square in the permits list. Compile before you touch any switch, and write down every error javac gives you. Then add the square cases until it compiles and runs.
2
Model the messages in a chat app as a sealed interface Message with three records: Text(String body), Photo(String file, int width, int height) and Voice(int seconds). Write preview(Message m) as a switch expression with record patterns that returns, for example, Photo 800x600 or Voice note, 12 s. Add a guard so an empty text previews as (empty message).
3
Write perimeter for Circle and Rect twice: once as a method in each record, once as a switch in Main. Then add Square to both versions. For each version, list the places you had to edit and say what told you about each one.
4
Extend the tiny calculator from the “going deeper” panel with a Sub record for subtraction. Add it to permits first and compile, so you see which switch javac flags, then make new Sub(new Num(10), new Mul(new Num(2), new Num(3))) print 4.

Further reading#

Spotted a mistake, or a part that did not make sense? Report it by email