Skip to content
elephantoo

Modern Java 17-21

Lesson 42 of 43 20 min read

Sealed classes, pattern matching for instanceof and switch, record patterns, text blocks, virtual threads and more.


Java has changed more since Java 8 than in its first two decades. A new feature release ships every six months, and every two years one becomes a Long-Term Support (LTS) release: 17 (2021), 21 (2023) and 25 (2025). This lesson tours the features from Java 17 to 21 that change how you write everyday code. Many of them work together: records + sealed types + pattern matching add up to a new, data-oriented style of programming in Java.

Quick recap of features you've already used#

FeatureSinceExample
var for local variables10var list = new ArrayList<String>();
Switch expressions with -> and yield14int days = switch (m) { case 2 -> 28; default -> 30; };
Text blocks15""" multi-line strings
Records16record Point(int x, int y) { }
Pattern matching for instanceof16if (o instanceof String s) s.length();
Helpful NullPointerException messages14Cannot invoke "String.length()" because "name" is null
Stream.toList()16stream.toList()

Sealed classes and interfaces (Java 17)#

Normally anyone can extend a class or implement an interface. A sealed type restricts which classes may extend it:

Sealed.java
public class Sealed {
    sealed interface Payment permits Upi, Card, Cash { }

    record Upi(String vpa) implements Payment { }                  // records are implicitly final
    record Card(String last4, boolean international) implements Payment { }
    static final class Cash implements Payment { }

    static double fee(Payment p, double amount) {
        return switch (p) {                                         // exhaustive: no default needed
            case Upi u -> 0;
            case Card c -> amount * (c.international() ? 0.035 : 0.018);
            case Cash cash -> 10;
        };
    }

    public static void main(String[] args) {
        System.out.println(fee(new Upi("asha@okbank"), 1000));
        System.out.println(fee(new Card("4242", true), 1000));
        System.out.println(fee(new Cash(), 1000));
    }
}
Output
0.0
35.0
10.0

Rules:

  • permits lists the allowed direct subtypes. If they're in the same file, permits can be omitted.
  • Every permitted subtype must be final, sealed (with its own list) or non-sealed (open again for extension).
  • Permitted classes must be in the same package (or the same module).

Why it matters: the compiler now knows every possible case. A switch over a sealed type is exhaustive without default, and if you later add record Wallet(...) implements Payment, every switch that forgot it becomes a compile error, not a runtime surprise. Sealed types model "one of a fixed set of shapes": payment methods, API results, AST nodes, events.

Pattern matching for instanceof (Java 16)#

Java
// Before
if (obj instanceof String) {
    String s = (String) obj;
    System.out.println(s.length());
}

// Now: test, cast and bind in one step
if (obj instanceof String s && !s.isBlank()) {
    System.out.println(s.length());
}

The pattern variable s is in scope wherever the compiler knows the test succeeded, including after && and in the rest of a method after an early return such as if (!(obj instanceof String s)) return;.

Pattern matching for switch (Java 21)#

switch can now match on types, with guards, and handle null:

SwitchPatterns.java
public class SwitchPatterns {
    static String describe(Object o) {
        return switch (o) {
            case null -> "null!";
            case Integer i when i < 0 -> "negative int " + i;
            case Integer i -> "int " + i;
            case String s when s.isBlank() -> "blank string";
            case String s -> "string of length " + s.length();
            case int[] arr -> "int array of " + arr.length;
            default -> "something else: " + o.getClass().getSimpleName();
        };
    }

    public static void main(String[] args) {
        Object[] samples = {42, -7, "hello", "   ", new int[]{1, 2, 3}, 3.14, null};
        for (Object o : samples) System.out.println(describe(o));
    }
}
Output
int 42
negative int -7
string of length 5
blank string
int array of 3
something else: Double
null!
  • case Type name -> matches by type and binds a variable.
  • when adds a guard condition.
  • Cases are checked top to bottom. The compiler rejects a case that can never match because an earlier one dominates it (e.g. case Integer i before case Integer i when i < 0).
  • case null is allowed. Without it, a null selector throws NullPointerException, as before.
  • Over non-sealed types like Object, a default is required for exhaustiveness.

Record patterns (Java 21)#

Record patterns deconstruct a record into its components, and they nest:

RecordPatterns.java
public class RecordPatterns {
    record Point(int x, int y) { }
    sealed interface Shape permits Circle, Rect, Line { }
    record Circle(Point centre, int radius) implements Shape { }
    record Rect(Point topLeft, Point bottomRight) implements Shape { }
    record Line(Point from, Point to) implements Shape { }

    static String info(Shape s) {
        return switch (s) {
            case Circle(Point(var x, var y), int r) when r == 0 -> "a dot at " + x + "," + y;
            case Circle(Point c, int r) -> "circle r=" + r + " at " + c;
            case Rect(Point(int x1, int y1), Point(int x2, int y2)) ->
                    "rect area " + Math.abs((x2 - x1) * (y2 - y1));
            case Line(Point from, Point to) when from.equals(to) -> "degenerate line";
            case Line l -> "line from " + l.from() + " to " + l.to();
        };
    }

    public static void main(String[] args) {
        System.out.println(info(new Circle(new Point(1, 2), 0)));
        System.out.println(info(new Circle(new Point(0, 0), 5)));
        System.out.println(info(new Rect(new Point(0, 0), new Point(4, 3))));
        System.out.println(info(new Line(new Point(1, 1), new Point(1, 1))));
        System.out.println(info(new Line(new Point(0, 0), new Point(2, 2))));

        Object obj = new Point(3, 4);
        if (obj instanceof Point(int x, int y)) {
            System.out.println("distance " + Math.sqrt(x * x + y * y));
        }
    }
}
Output
a dot at 1,2
circle r=5 at Point[x=0, y=0]
rect area 12
degenerate line
line from Point[x=0, y=0] to Point[x=2, y=2]
distance 5.0

You can use var for component types, and from Java 22 the unnamed pattern _ for components you don't need: case Circle(_, int r).

Data-oriented programming: putting it together#

Sealed interfaces + records + pattern-matching switches give you algebraic data types: a precise, compiler-checked model of your domain's possibilities. A typical example is modelling the result of an operation without exceptions:

Results.java
import java.util.List;

public class Results {
    sealed interface Result<T> permits Ok, Err { }
    record Ok<T>(T value) implements Result<T> { }
    record Err<T>(String message) implements Result<T> { }

    static Result<Integer> parseAge(String input) {
        try {
            int age = Integer.parseInt(input.strip());
            return age < 0 || age > 150 ? new Err<>("age out of range: " + age) : new Ok<>(age);
        } catch (NumberFormatException e) {
            return new Err<>("not a number: '" + input + "'");
        }
    }

    public static void main(String[] args) {
        for (String in : List.of("34", "-3", "abc")) {
            String msg = switch (parseAge(in)) {
                case Ok<Integer>(Integer age) -> "valid age " + age;
                case Err<Integer>(String error) -> "error: " + error;
            };
            System.out.println(msg);
        }
    }
}
Output
valid age 34
error: age out of range: -3
error: not a number: 'abc'

Compare this with the classic OOP approach of putting a method on each subclass. Both are valid. Pattern matching is best when the set of types is fixed but you keep adding operations (reports, validations, conversions); virtual methods are best when the operations are fixed but you keep adding types.

Text blocks (Java 15) in more depth#

TextBlocks.java
public class TextBlocks {
    public static void main(String[] args) {
        String name = "Asha";
        String html = """
            <div class="card">
              <h1>Hello, %s!</h1>
            </div>
            """.formatted(name);
        System.out.print(html);

        String oneLine = """
            This is a long sentence \
            that stays on one line.""";
        System.out.println(oneLine);

        String spaces = """
            keep trailing spaces:\s\s
            done""";
        System.out.println(spaces.lines().map(l -> "[" + l + "]").toList());
    }
}
Output
<div class="card">
  <h1>Hello, Asha!</h1>
</div>
This is a long sentence that stays on one line.
[[keep trailing spaces:  ], [done]]
  • Indentation is stripped based on the least-indented line (including the closing """).
  • \ at the end of a line joins it with the next line; \s keeps a space that would otherwise be stripped.
  • Combine them with formatted(...) for templates. (String templates were previewed in Java 21-22 and then withdrawn, so formatted remains the way.)

Virtual threads (Java 21)#

Covered in the executors lesson. The essentials:

Java
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
    for (String url : urls) {
        executor.submit(() -> fetch(url));     // one cheap virtual thread per blocking task
    }
}
Thread.ofVirtual().start(() -> System.out.println("hi"));
Thread.startVirtualThread(() -> System.out.println("also hi"));

They let you write simple, blocking, thread-per-request code that scales like asynchronous code for I/O-heavy workloads. In Spring Boot 3.2+, enable them with spring.threads.virtual.enabled=true.

Sequenced collections (Java 21)#

New interfaces SequencedCollection, SequencedSet and SequencedMap give every ordered collection a uniform API:

SequencedDemo.java
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.SequencedMap;

public class SequencedDemo {
    public static void main(String[] args) {
        List<Integer> list = new ArrayList<>(List.of(1, 2, 3));
        System.out.println(list.getFirst() + " " + list.getLast() + " " + list.reversed());

        LinkedHashSet<String> set = new LinkedHashSet<>(List.of("b", "c"));
        set.addFirst("a");
        System.out.println(set + " " + set.getLast());

        SequencedMap<String, Integer> map = new LinkedHashMap<>();
        map.put("one", 1);
        map.put("two", 2);
        map.putFirst("zero", 0);
        System.out.println(map.firstEntry() + " " + map.lastEntry() + " " + map.reversed());
    }
}
Output
1 3 [3, 2, 1]
[a, b, c] c
zero=0 two=2 {two=2, one=1, zero=0}

Other handy additions (Java 11-21)#

Handy.java
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;

public class Handy {
    public static void main(String[] args) {
        System.out.println("  hi  ".strip() + "|" + " ".isBlank() + "|" + "ab".repeat(2));   // Java 11
        System.out.println("a\nb\nc".lines().count());                                       // Java 11
        System.out.println("  indented".indent(2).stripTrailing());                           // Java 12
        System.out.println(Stream.of(1, 2, 3, 4).mapMulti((Integer n, java.util.function.Consumer<Integer> sink) -> {
            if (n % 2 == 0) { sink.accept(n); sink.accept(n * 10); }
        }).toList());                                                                          // Java 16
        Map<Boolean, Long> split = Stream.of(5, 8, 13)
                .collect(Collectors.partitioningBy(n -> n > 6, Collectors.counting()));
        System.out.println(split);
        var sb = new StringBuilder("racecar");
        System.out.println(sb.reverse().toString().equals("racecar"));
        System.out.println(Math.clamp(150, 0, 100));                                           // Java 21
        System.out.println(List.of(3, 1, 2).stream().sorted().toList());
    }
}
Output
hi|true|abab
3
    indented
[2, 20, 4, 40]
{false=1, true=2}
true
100
[1, 2, 3]

Other notable changes: the java.net.http.HttpClient (Java 11) for HTTP calls, running single-file programs with java File.java (Java 11), jpackage for native installers (Java 16), and generational ZGC (Java 21).

A peek beyond Java 21#

Java 25 (LTS, September 2025) finalised several features that build on 21:

  • Compact source files and instance main methods: a beginner program can be just void main() { IO.println("Hello"); }.
  • Unnamed variables and patterns with _ (final in Java 22).
  • Flexible constructor bodies: statements before super(...), e.g. for validation.
  • Module import declarations: import module java.base;.
  • Scoped values for safely sharing data with virtual threads.

Everything in this course runs on Java 21 and on these newer versions.

Common mistakes#

  • Forgetting that permitted subclasses of a sealed type must be final, sealed or non-sealed.
  • Ordering switch cases so a general pattern comes before a specific one (a compile error: "this case label is dominated by a preceding case label").
  • Adding default to a switch over a sealed type. It compiles, but you lose the compile-time warning when a new subtype appears.
  • Using pattern matching where a simple polymorphic method would be clearer.
  • Assuming preview features (--enable-preview) are stable. Don't use them in production code.

What's next#

You now know modern core Java. The final lesson is your next step into professional back-end development: an introduction to Spring Boot.

Check your understanding

Quick quiz

0/3 answered
  1. 1.What does sealed interface Shape permits Circle, Square {} guarantee?

  2. 2.In Java 21, what does case Circle c when c.radius() > 10 -> do in a switch?

  3. 3.Which statement about record patterns is correct?

Finished reading?

Mark this lesson complete to track your progress.