← Back to the section

Java was long criticized for its verbosity: to describe a simple data "box" with three fields, you had to write a constructor, getters, equals, hashCode, and toString by hand. Over the recent versions (17–21) the language has caught up considerably. Let's go through the features that make modern code shorter and safer, starting with the most useful one — record.

you write one line — the compiler writes the rest record Point(int x, int y) what you write compiler and what it generates Point(int, int) x() y() equals(Object) hashCode() toString() new Point(3, 4).equals(new Point(3, 4)) → true: fields compared by value

You declare only the components. The constructor, accessors, equals, hashCode and toString are written by the compiler — and equals compares values, not references.

Why any of this: the verbosity problem

Imagine a class that just holds data — a point on a map, an order line, coordinates. It used to look like this:

public final class Point {
    private final int x;
    private final int y;

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public int x() { return x; }
    public int y() { return y; }

    @Override
    public boolean equals(Object o) { /* ... 10 lines ... */ }
    @Override
    public int hashCode() { /* ... */ }
    @Override
    public String toString() { /* ... */ }
}

Thirty lines for the sake of two numbers. There's zero logic here — pure boilerplate that's easy to get wrong (forget a field in equals). This is exactly the pain that record removes.

record in more detail

record is a special kind of class for holding immutable data. You declare only the fields, and the compiler generates the constructor, accessor methods, equals, hashCode, and toString for you.

public record Point(int x, int y) {}

One line does everything the 30 lines above did. You use it like this:

live example

public class RecordDemo {
    record Point(int x, int y) {}

    public static void main(String[] args) {
        Point p = new Point(3, 4);
        System.out.println(p.x());                      // 3 — the accessor is named after the field, no get
        System.out.println(p);                          // Point[x=3, y=4] — ready-made toString
        System.out.println(p.equals(new Point(3, 4)));  // true — comparison by field values
    }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Key properties of a record:

  • Immutability. Fields are final; you can't change them after creation. To "modify" a point, you create a new one.
  • Accessors are named after the fields: p.x(), not p.getX().
  • equals/hashCode compare by the value of all fields. This makes a record an ideal key for a Map or an element of a Set.

You can add your own methods and checks in the constructor. A compact constructor lets you validate arguments without listing the assignments:

public record Point(int x, int y) {
    public Point {                       // compact constructor — no parameter parentheses
        if (x < 0 || y < 0) {
            throw new IllegalArgumentException("Coordinates cannot be negative");
        }
        // the assignment this.x = x; is added by the compiler
    }

    public double distanceToOrigin() {   // your own method — go ahead
        return Math.sqrt(x * x + y * y);
    }
}

Use a record for DTOs, keys, coordinates — any immutable "boxes of values." When you need mutable state or inheritance, take a regular class.

Immutability covers the reference only

record makes the fields final, not the objects behind them. If a component is an array or a mutable collection, its contents can still be changed from the outside, and for an array the generated equals and hashCode compare references, not elements:

live example

import java.util.List;

public class RecordArrayDemo {
    record Bad(int[] coords) {}                      // don't: an array as a component

    record Good(List<Integer> coords) {              // do: an immutable type
        Good {
            coords = List.copyOf(coords);            // defensive copy on the way in
        }
    }

    public static void main(String[] args) {
        System.out.println(new Bad(new int[]{1, 2}).equals(new Bad(new int[]{1, 2})));    // false
        System.out.println(new Good(List.of(1, 2)).equals(new Good(List.of(1, 2))));      // true
    }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Rule: components of a record are immutable types — List<Integer> instead of an array, with a defensive List.copyOf in the compact constructor. Then comparison goes by content, and outside code cannot change the point. If an array is unavoidable, override equals, hashCode, and toString via Arrays.* and copy it on the way in and out.

Optional: how to stop fearing NPE

NullPointerException (NPE) is the most common runtime error in Java. It happens when you call a method on a variable that is actually null. A method that returns "maybe a value, maybe nothing" used to return null — and it was easy to forget the check.

Optional<T> is a "box" that either contains a value or is empty. It tells the caller explicitly: "there may be no result here, handle both cases."

Optional<User> found = repository.findById(42);   // explicit: may not be found

// safely retrieve the value or substitute a fallback
User user = found.orElse(User.guest());

// or react only if the value is present
found.ifPresent(u -> System.out.println("Hello, " + u.name()));

The power of Optional is in chains of transformations without a scatter of if (x != null):

live example

import java.util.Map;
import java.util.Optional;

public class OptionalDemo {
    record Address(String city) {}
    record User(String name, Address address) {}

    static final Map<Integer, User> USERS = Map.of(42, new User("Ann", new Address("Berlin")));

    static Optional<User> findById(int id) {
        return Optional.ofNullable(USERS.get(id));
    }

    static String cityOf(int id) {
        return findById(id)          // Optional<User>
                .map(User::address)  // Optional<Address>
                .map(Address::city)  // Optional<String>
                .orElse("unknown");  // String, without a single NPE
    }

    public static void main(String[] args) {
        System.out.println(cityOf(42));  // Berlin
        System.out.println(cityOf(7));   // unknown — no such user
    }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

If a value is missing at any step, the chain quietly "falls through" into an empty Optional.

Optional antipatterns

Optional is easy to misuse. What not to do:

  • Don't call .get() without a check. opt.get() on an empty Optional throws an exception — that's just an NPE in disguise. Use orElse, orElseThrow, ifPresent, map.
  • Don't make class fields Optional. Optional was designed for method return values, not for storage. Let a field be plain, possibly null inside.
  • Don't pass Optional as a method parameter. It muddles the call. Better to add a method overload or accept a plain value.
  • Don't wrap collections. An empty List already means "there's nothing" — Optional<List<...>> is redundant. Return an empty list.

switch expressions

The old switch was a statement: it performed actions, required break (forget it and you fall through to the next branch), and didn't return a value. The modern switch can be an expression — that is, return a result that you assign to a variable right away.

live example

public class SwitchDemo {
    enum Day { MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY }

    public static void main(String[] args) {
        for (Day dayOfWeek : Day.values()) {
            String day = switch (dayOfWeek) {   // arrow syntax: no break, no fall-through
                case MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY -> "weekday";
                case SATURDAY, SUNDAY -> "weekend";
            };
            System.out.println(dayOfWeek + " — " + day);
        }
    }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Advantages: several labels separated by commas, the whole expression returns a value. For an enum, the compiler also checks that all cases are handled. If a branch needs several lines, use a block with yield:

int code = switch (status) {
    case ACTIVE -> 1;
    case BLOCKED -> {
        log.warn("Blocked status");
        yield 0;                 // yield returns a value from the block
    }
};

sealed classes and pattern matching in brief

A sealed class (or interface) restricts the list of subtypes: only the listed types may implement it. This tells both the compiler and the reader: "there are exactly this many variants, no others."

Combined with pattern matching in switch, this gives a compact and safe dispatch by type. Previously you wrote if (s instanceof Circle) { Circle c = (Circle) s; ... } — with an explicit cast. Now the variable is declared right in the check:

live example

public class ShapeDemo {
    sealed interface Shape permits Circle, Square {}
    record Circle(double radius) implements Shape {}
    record Square(double side) implements Shape {}

    static double area(Shape shape) {
        return switch (shape) {
            case Circle c -> Math.PI * c.radius() * c.radius();  // c is already a Circle, no cast
            case Square s -> s.side() * s.side();
        };
    }

    public static void main(String[] args) {
        System.out.println(area(new Circle(1)));   // 3.141592653589793
        System.out.println(area(new Square(3)));   // 9.0
    }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

Because Shape is sealed, the compiler knows all the variants and won't require a default branch. Add a third shape and it will point out where the switch became incomplete. Pattern matching also works in a plain instanceof:

if (obj instanceof String str && !str.isBlank()) {
    System.out.println(str.length());   // str is available and already a String
}

text blocks

Multiline strings (JSON, SQL, HTML) used to be glued together from pieces with \n and escaped quotes — impossible to read. A text block is a string in triple quotes that keeps line breaks and quotes as they are:

live example

public class TextBlockDemo {
    public static void main(String[] args) {
        String json = """
                {
                    "name": "Ivan",
                    "city": "Moscow"
                }
                """;
        System.out.print(json);
        System.out.println("length: " + json.length() + " — left indentation stripped");
    }
}
Run

Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →

The compiler trims the left indentation to the leftmost non-empty line. Handy for embedded SQL queries and test data.

What else arrived in modern Java

A few small things you'll run into right away. The version next to each is worth remembering: on an older build they simply will not compile.

  • var (Java 10) — local variable type inference: var users = new ArrayList<User>();. The type is still static, you just don't write it twice. Only for local variables, where the type is obvious from the right-hand side.
  • List.of, Map.of (Java 9) — quick immutable collections: List.of("a", "b").
  • Helpful NullPointerExceptions (Java 14, on by default since 15) — the NPE message now names exactly which variable turned out to be null.
  • Virtual threads (Java 21) — lightweight threads for scalable I/O; the details are a topic for the concurrency section, here just know they exist.

How to read Java versions

It pays to understand not only individual features but how the language's evolution is structured — otherwise it's easy to get lost in version numbers.

The release model. Since Java 9, versions ship every six months, but they aren't equal. Every 2–3 years an LTS (long-term support) release comes out — the one you actually run in production: 8, 11, 17, 21 (then 25). The in-between ones (18, 19, 20…) are a showcase of new capabilities, often in preview status. So "we're on 21 LTS" is a normal stance: chasing every six-month release is not the point.

Lines, not numbers. Features aren't random — they fall into a few lines, and it's more useful to keep those in mind than "what shipped in which version":

  • Less boilerplate and value semantics — record, var, List.of: the language writes what used to be written by hand.
  • Safe modeling — sealed + pattern matching + switch expression: the compiler checks that all cases are handled.
  • Scalable I/O — virtual threads (see concurrency): the thread-per-task model is cheap again.
  • Low-latency garbage collection — modern collectors G1 and ZGC (see garbage collection).

In short

  • record generates the constructor, accessors, equals/hashCode/toString — use it for immutable data classes (DTOs, keys, value objects).
  • Optional<T> is an honest return type "the value may be absent"; retrieve it via orElse/map/ifPresent, not via .get().
  • Optional antipatterns: .get() without a check, Optional in fields and parameters, wrapping collections.
  • switch expression returns a value, needs no break, checks exhaustiveness for an enum.
  • sealed + pattern matching give safe dispatch over a fixed set of types without manual casting.
  • Text block (""") — readable multiline strings without escaping.
  • var, List.of, clear NPEs, and virtual threads — the pleasant small stuff of modern Java.