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 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(), notp.getX(). equals/hashCodecompare by the value of all fields. This makes a record an ideal key for aMapor an element of aSet.
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. UseorElse,orElseThrow,ifPresent,map. - Don't make class fields
Optional. Optional was designed for method return values, not for storage. Let a field be plain, possiblynullinside. - Don't pass
Optionalas a method parameter. It muddles the call. Better to add a method overload or accept a plain value. - Don't wrap collections. An empty
Listalready 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 +switchexpression: 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
recordgenerates 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 viaorElse/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 anenum. 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.
What to read next
- Lambdas and the Stream API — where
Optional.mapandswitchshow up most often. - OOP in Java — how
recordandsealeddiffer from regular classes and inheritance. - Tooling and build — how to build and run a project on the current version of the language.