Code written in Java 8 still compiles under Java 25. And yet code written today looks completely different. Not because Java was rewritten from scratch — because across seventeen versions, ceremony was systematically stripped away and constructs were added that let you describe your domain model instead of hand-rolling its plumbing.

The shortest possible illustration:

// Java 8
if (obj instanceof User) {
    User user = (User) obj;
    System.out.println(user.getName());
}

// Java 25
if (obj instanceof User user) {
    System.out.println(user.name());
}

Three things disappeared: the cast, the helper-variable declaration, and the getter ceremony. None of them disappeared by accident.

What "a change in Java" actually means

This article is about the language, not the whole JDK. That distinction matters more than it looks, because discussions of "what's new in Java" tend to blur four completely different layers:

LayerWhat changesWhere it is specifiedExample
LanguageSyntax and semantics the compiler understandsJava Language Specification (JLS)record, sealed, pattern matching
CompilerHow source is translated to bytecodethe javac implementationlambdas translated via invokedynamic
JVMExecution model, class file format, GCJVM Specificationvirtual threads, ZGC, compact object headers
APINew classes in the standard libraryJavadocStream API, HttpClient, IO

The Stream API is not a language change — it is a library. Virtual threads are not a language change — they are runtime. But var, record, and switch pattern matching are grammar changes that a Java 8 compiler simply would not understand.

This article covers primarily that first layer, with one deliberate exception (the Stream API), because the story of lambdas is incomplete without it.

Thesis

Java did not add syntactic sugar for its own sake. Each new construct solves a problem left behind by the previous one, and the whole sequence lines up in one coherent direction: from describing how to perform an operation, to describing what the data's structure is and what its possible cases are.

Java 8 (2014): the functional revolution

Lambda expressions

Status: FINAL (Java 8)

Problem. Passing behavior as an argument required an anonymous class. Five lines of ceremony for one line of logic.

Why it was introduced. So behavior could become a value — passable, composable, returnable. That is a precondition for any functional style at all.

// Before
button.addActionListener(new ActionListener() {
    @Override
    public void actionPerformed(ActionEvent e) {
        save();
    }
});

// After
button.addActionListener(e -> save());

Syntax variants:

(a, b) -> a + b                        // multiple parameters, expression
() -> System.out.println("Hello")      // no parameters
value -> value * 2                     // one parameter, parentheses optional
(String s) -> s.length()               // explicit types
x -> { int y = x * 2; return y + 1; }  // block lambda

Four things worth understanding:

  1. A lambda has no type of its own. There is no such thing as "the type of a lambda". x -> x * 2 on its own is incomplete.
  2. It needs a functional interface. The type comes from context, and that context must be an interface with exactly one abstract method.
  3. Target typing. The same lambda can have different types depending on where it is used.
  4. Expression vs. block. x -> x * 2 returns a value implicitly; x -> { ... } requires return.
Runnable r = () -> doSomething();
Action a = () -> doSomething();   // different type, identical lambda

Additionally: a lambda does not introduce a new scope for this. In an anonymous class, this pointed at the anonymous class instance; in a lambda it points at the enclosing class. That is not a minor detail — it is a frequent source of bugs during migration.

Functional interfaces

Status: FINAL (Java 8)

Problem. Since a lambda has no type, a formal contract was needed: how many parameters, of what types, returning what.

Why it was introduced. To formalize "a single abstract method" as a lambda target — and to do it in a backward-compatible way. Runnable, Comparator, and Callable had existed for years and became lambda targets without any change at all.

@FunctionalInterface
interface Calculator {
    int calculate(int a, int b);
}

Calculator add = (a, b) -> a + b;
int result = add.calculate(2, 3);

The @FunctionalInterface annotation is optional — it exists purely so the compiler flags an error if a second abstract method is added.

Standard interfaces from java.util.function:

Predicate<T>            // T  -> boolean
Function<T, R>          // T  -> R
BiFunction<T, U, R>     // T,U -> R
Consumer<T>             // T  -> void
Supplier<T>             // () -> T
UnaryOperator<T>        // T  -> T      (Function<T,T>)
BinaryOperator<T>       // T,T -> T     (BiFunction<T,T,T>)

Combining lambdas and functional interfaces created the foundation the Stream API could be built on. Without one, the other would not exist.

Method references

Status: FINAL (Java 8)

Problem. Very often a lambda did nothing but delegate to an existing method. user -> System.out.println(user) is pure noise.

// Before
users.forEach(user -> System.out.println(user));

// After
users.forEach(System.out::println);

Four kinds:

String::valueOf          // static method
System.out::println      // instance method of a specific object
String::toUpperCase      // instance method of an arbitrary object of a given type
User::new                // constructor

The third case can be confusing: String::toUpperCase as a Function<String, String> means "take the argument and call toUpperCase() on it". The receiver becomes the parameter.

Default methods on interfaces

Status: FINAL (Java 8)

Problem. Adding a method to a public interface broke every existing implementation. Interfaces were frozen the moment they were published.

Why it was introduced. The direct driver was the Stream API — Collection.stream() had to land on Collection, and that interface could not be changed without breaking the whole ecosystem.

// Before
interface Vehicle {
    void drive();
}
// Adding stop() = breaks every implementation

// After
interface Vehicle {
    void drive();

    default void stop() {
        System.out.println("Stopping");
    }
}

Conflicting default methods. When a class inherits contradictory implementations, the compiler applies three rules:

  1. A class wins over an interface. A method from a base class takes priority over a default method.
  2. The more specific interface wins. If B extends A, the default in B overrides the default in A.
  3. Otherwise: a compile error. The programmer must resolve it explicitly.
class Hybrid implements Car, Boat {
    @Override
    public void move() {
        Car.super.move();   // explicit choice
    }
}

This was a deliberate design decision: Java did not introduce multiple inheritance of state, only multiple inheritance of behavior, and it forces explicit conflict resolution.

Static methods on interfaces

Status: FINAL (Java 8)

Problem. Helper methods semantically tied to an interface had to live in separate XxxUtils classes — hence Collections, Arrays, Objects.

// Before
MathUtils.square(5);

// After
interface MathUtils {
    static int square(int x) {
        return x * x;
    }
}
MathUtils.square(5);

Why static interface methods are not inherited. Default methods are part of the implementation contract — that is why they propagate to subtypes. Static methods belong to the interface itself as a namespace, not to the contract. If they were inherited, conflicts from implementing multiple interfaces would be unresolvable, and calling them through an instance would make no sense. Hence:

class Impl implements MathUtils {}

Impl.square(5);       // does not compile
MathUtils.square(5);  // OK

Type annotations and repeating annotations

Status: FINAL (Java 8)

Problem. Annotations could only be placed on declarations (a class, method, field, parameter). There was no way to mark a type at its point of use, nor to repeat the same annotation.

Why it was introduced. For external tooling: static analysis, nullability, dependency injection, type checkers.

// Type annotations — on the type, not on the declaration
List<@NonNull String> names;
@NonNull String s = getValue();
Map<@KeyFor("x") String, Integer> map;

// Repeating annotations
@Role("ADMIN")
@Role("USER")
class User {}

Repeating annotations are effectively syntactic sugar — the compiler wraps them in a container:

@Repeatable(Roles.class)
@interface Role { String value(); }

@interface Roles { Role[] value(); }

Worth stating clearly: this is an extension of the annotation system, not a construct you write day to day. In typical application code these changes are nearly invisible — they work under the hood, in the Checker Framework, in Spring, in Bean Validation.

Stream API — a style change, not a language change

Status: FINAL (Java 8) — an API, not a language construct

It is a library. But its impact on what Java code looks like is bigger than half the language changes combined — so leaving it out would be dishonest.

Problem. Every collection transformation meant a manual loop, a mutated result list, and "what we do" tangled with "how we iterate".

// Before
List<String> result = new ArrayList<>();
for (User user : users) {
    if (user.isActive()) {
        result.add(user.getName());
    }
}

// After
List<String> result = users.stream()
    .filter(User::isActive)
    .map(User::getName)
    .toList();

Anatomy of a pipeline:

  1. Source — a collection, array, generator, Files.lines().
  2. Intermediate operations — filter, map, sorted, distinct, limit. They are lazy: calling them executes nothing, they only build a description.
  3. Terminal operation — toList, collect, forEach, reduce, findFirst. Only this triggers the whole thing.

Laziness is not an implementation detail. Thanks to it, stream().filter(...).findFirst() stops after the first hit instead of filtering the whole collection.

// Nothing happens — no terminal operation
users.stream().filter(User::isActive).map(User::getName);

Stream.toList() from the example above comes from Java 16. In Java 8 you had to write .collect(Collectors.toList()). That is a good illustration of the fact that the API evolves in parallel with the language.

Java 9 (2017): small closures

Java 9 is associated with modules (JPMS), but from the perspective of everyday syntax it brought three small fixes — gathered under JEP 213, "Milling Project Coin".

Private methods on interfaces

Status: FINAL (Java 9)

Problem. Default methods could contain logic, but had nowhere to hide shared helper code. The only options were a default or static method — i.e. leaking an implementation detail into the public API.

// Before
interface Logger {
    default void info(String msg) {
        System.out.println("INFO: " + msg);
    }

    default void error(String msg) {
        System.out.println("ERROR: " + msg);   // duplication
    }
}

// After
interface Logger {
    default void info(String msg)  { log("INFO", msg); }
    default void error(String msg) { log("ERROR", msg); }

    private void log(String level, String msg) {
        System.out.println(level + ": " + msg);
    }
}

private static methods are also available — for helper methods used by an interface's static methods.

Diamond operator for anonymous classes

Status: FINAL (Java 9)

// Before
List<String> list = new ArrayList<String>() {};

// After
List<String> list = new ArrayList<>() {};

Why it was not there already in Java 7. An anonymous class creates a new, unnamed type. If the inferred type were non-denotable — e.g. an intersection type — the compiler would have no way to write it into the class file. Java 9 allows <> exactly when the inferred type can be expressed.

Try-with-resources without redeclaration

Status: FINAL (Java 9)

Problem. A resource created earlier had to be assigned to a new variable inside try, purely to satisfy the syntax requirement.

// Before
BufferedReader reader = createReader();
try (BufferedReader r = reader) {
    System.out.println(r.readLine());
}

// After
BufferedReader reader = createReader();
try (reader) {
    System.out.println(reader.readLine());
}

Effectively final is the key concept here — a variable that was never reassigned after initialization, even without the final modifier. The same condition applies to variables captured by lambdas and anonymous classes. The reason is the same in both places: without a guarantee of immutability there is no safe way to reason about the value's lifetime and visibility.

Java 10 (2018): var

Status: FINAL (Java 10) — JEP 286

Problem. Generic types in local declarations could be longer than the rest of the line, and the information on the left of the = sign just repeated what was already on the right.

Why it was introduced. To reduce noise while keeping full static typing.

// Before
Map<String, List<User>> usersByCountry = new HashMap<String, List<User>>();

// After
var usersByCountry = new HashMap<String, List<User>>();

A fundamental point that needs to be said outright:

var user = new User();

is still statically typed code. The type of user is User, it is fixed at compile time, and it cannot change. var is not the JavaScript var, nor is it Object. It is purely a shorthand for a type the compiler already knows.

Restrictions — they all follow from a single rule: the compiler must have something to infer the type from.

var x = null;               // null has no type
var x;                       // no initializer
var x = () -> {};            // a lambda needs a target type
var x = String::valueOf;     // same for a method reference
var[] arr = {1, 2};           // not allowed

class User {
    var name;                // local variables only
}

void process(var input) {}   // not for method parameters
var compute() { ... }        // not for return types

Allowed places: local variables with an initializer, the for-loop variable, the try-with-resources variable, the for-each index variable.

A syntax curiosity: var is not a keyword, it is a reserved type name. That is why this still compiles:

var var = 1;                  // legal, if cruel

This meant existing code where var was a variable or method name was not broken.

Java 11 (2018): var in lambda parameters

Status: FINAL (Java 11) — JEP 323

Problem. Lambda parameters could be written two ways: without a type ((name, email) -> ...) or with the full type ((String name, String email) -> ...). There was no third option — and one was needed, to add an annotation or the final modifier without spelling out the types.

(var name, var email) -> send(name, email)

With an annotation:

(@Nonnull var name, @Nonnull var email) -> send(name, email)

With a modifier:

(final var name, final var email) -> send(name, email)

Styles cannot be mixed in a single parameter list:

(var name, String email) -> ...   // not allowed
(var name, email) -> ...          // not allowed
(var name, var email) -> ...      // OK

The reason is purely pragmatic: mixing styles would obscure which parameters are inferred, for no real benefit.

Note the order: the annotation comes before var, not after. (var @Nonnull name, ...) does not compile — var occupies the position of the type, and type/declaration annotations precede the type.

Java 12–14: switch expressions

Status: PREVIEW (Java 12, JEP 325) → PREVIEW (Java 13, JEP 354) → FINAL (Java 14, JEP 361)

Problem. A classic switch was a statement. To get a value out of it you had to declare a variable before it, mutate it in every branch, and remember break. A forgotten break caused fall-through — legal, silent, and almost always a bug.

// Before
int days;
switch (month) {
    case 1:
    case 3:
        days = 31;
        break;
    case 2:
        days = 28;
        break;
    default:
        days = 30;
}

// After
int days = switch (month) {
    case 1, 3 -> 31;
    case 2    -> 28;
    default   -> 30;
};

What changed:

  1. The -> arrow — a new label form. Only its right-hand side executes.
  2. No fall-through — with the arrow form, control does not flow into the next branch. break is unnecessary and disallowed.
  3. Multiple labels per case — case 1, 3 -> instead of stacking case statements.
  4. Expression vs. statement — switch can now be an expression that returns and can be assigned a value. The old statement form still works.
  5. Exhaustiveness — a switch used as an expression must cover every possible value. For enums it is enough to list every constant, no default needed:
String label = switch (status) {
    case ACTIVE   -> "active";
    case INACTIVE -> "inactive";
    case PENDING  -> "pending";
};   // OK — no default, since the enum has exactly these three values

That last point is more valuable than it looks: when someone adds a fourth constant to the enum, the code stops compiling. With a default, the error would only surface in production.

yield

Status: FINAL (Java 14) — introduced in the second preview round (Java 13)

Problem. A switch-expression branch sometimes needs several statements. A { } block had to somehow return a value, and return was already taken.

int result = switch (value) {
    case 1 -> 10;
    case 2 -> {
        int calculated = calculate();
        yield calculated * 2;
    }
    default -> 0;
};

The difference between yield and return:

  • yield ends the switch expression and supplies its value. Control returns to the code right after the switch.
  • return ends the entire method.

Inside a switch expression, return is explicitly forbidden — precisely because it would be ambiguous.

yield also works with the old, colon-based syntax:

int days = switch (month) {
    case 1, 3: yield 31;
    default:   yield 30;
};

And like var, yield is not a keyword, only a contextual name — existing methods called yield keep working.

Java 13–15: Text Blocks

Status: PREVIEW (Java 13, JEP 355) → PREVIEW (Java 14, JEP 368) → FINAL (Java 15, JEP 378)

Problem. Multi-line text — JSON, SQL, HTML, XML — required \n, escaped \" characters, and concatenation. The result was unreadable and could not be copy-pasted to or from an external tool.

// Before
String json = "{\n" +
              "  \"name\": \"John\",\n" +
              "  \"age\": 30\n" +
              "}";

// After
String json = """
    {
      "name": "John",
      "age": 30
    }
    """;

Three mechanisms you need to know:

1. Incidental whitespace. The compiler strips the indentation common to every line — counted together with the position of the closing """. That is why the position of the closing delimiter controls the indentation:

String a = """
    text
    """;      // "text\n"  — no indentation

String b = """
    text
""";          // "    text\n" — four spaces of indentation

2. The newline character. Text after the opening """ must start on a new line, and every line ends with \n. To avoid that, a \ is used at the end of the line:

String sql = """
    SELECT id, name \
    FROM users \
    WHERE active = true
    """;   // one line

3. Escaping. Quotes do not need to be escaped. The new \s escape represents a space that will not be eaten by trailing-whitespace stripping.

A text block is a plain String — there is no separate type, no variable interpolation. String Templates were in preview in Java 21 and 22, then were withdrawn and sent back for redesign. Java 25 does not have them.

Java 14–16: Pattern Matching for instanceof

Status: PREVIEW (Java 14, JEP 305) → PREVIEW (Java 15, JEP 375) → FINAL (Java 16, JEP 394)

Problem. The "check the type → cast → assign" sequence repeated the type name three times and was entirely redundant. The compiler already knew the cast was safe.

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

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

String s is a type pattern — simultaneously a type test and a pattern-variable declaration.

Flow scoping. The pattern variable's scope is not determined by curly braces, but by flow analysis: the variable is visible exactly where the compiler knows the match succeeded.

if (obj instanceof String s && !s.isBlank()) {
    // s is visible — && guarantees the left side was true
}

if (obj instanceof String s || s.isEmpty()) {
    // s is NOT available — the right side of || runs when the match failed
}

The negated, early-return pattern is especially useful:

if (!(obj instanceof String s)) {
    return;
}
// s is visible for the REST of the method
System.out.println(s.length());

This is not a trick — it is a direct consequence of the fact that after return inside an if block, control reaches further only when the match succeeded. The compiler knows that.

A classic use case — equals:

@Override
public boolean equals(Object o) {
    return o instanceof Point p
        && p.x == x
        && p.y == y;
}

Java 14–16: Records

Status: PREVIEW (Java 14, JEP 359) → PREVIEW (Java 15, JEP 384) → FINAL (Java 16, JEP 395)

Problem. A class whose only job is to carry data required dozens of lines, none of which carried information. Constructor, getters, equals, hashCode, toString — all mechanical, all error-prone when a field was added.

Why it was introduced. To let you declare what the data is, instead of implementing how it is handled. Brian Goetz called this "nominal tuples".

// Before
final class Point {
    private final int x;
    private final int y;

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

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

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

// After
record Point(int x, int y) {}

What the compiler generates:

ElementDetails
Fieldsprivate final for every component
Canonical constructorAccepts all components in declaration order
Accessorsx(), y() — component name, no get prefix
equalsComponent-by-component comparison
hashCodeConsistent with equals
toStringPoint[x=1, y=2]

Restrictions that are deliberate:

  • A record is implicitly final — it cannot be extended.
  • It cannot extend another class (it extends java.lang.Record).
  • It cannot have additional instance fields — the state is fully described by the header.
  • It can implement interfaces, have methods, static fields, alternate constructors, and nested types.

Shallow vs. deep immutability. This is the most common misunderstanding:

record Team(String name, List<Player> players) {}

var team = new Team("A", new ArrayList<>(List.of(p1)));
team.players().add(p2);   // compiles and works — the list has changed

The players reference is final. The object it points to is not immutable. A record gives shallow immutability; deep immutability is the programmer's responsibility:

record Team(String name, List<Player> players) {
    Team {
        players = List.copyOf(players);   // defensive copy
    }
}

Compact constructors

Status: FINAL (Java 16) — part of the records mechanism since the first preview in Java 14

Problem. Validating or normalizing data required writing a full canonical constructor — that is, re-listing every parameter and every this.x = x assignment.

// Before — full canonical constructor
record User(String name, int age) {
    User(String name, int age) {
        if (age < 0) {
            throw new IllegalArgumentException();
        }
        this.name = name;
        this.age = age;
    }
}

// After — compact constructor
record User(String name, int age) {
    User {
        if (age < 0) {
            throw new IllegalArgumentException();
        }
    }
}

No parameter list and no assignments. The compiler appends this.name = name; this.age = age; at the end of the block — and that is the key to understanding normalization:

record User(String name, int age) {
    User {
        name = name.trim();    // modifying the PARAMETER, not the field
        // the compiler appends: this.name = name;
    }
}

Assigning to this.name inside a compact constructor is a compile error. The spec describes this construct exactly as a shorthand where the programmer's code focuses on validation and normalization, and the mechanical component assignment is performed by the compiler.

Java 15–17: Sealed classes and interfaces

Status: PREVIEW (Java 15, JEP 360) → PREVIEW (Java 16, JEP 397) → FINAL (Java 17, JEP 409)

Problem. An inheritance hierarchy was either fully open or fully closed (final). There was no way to express: "there are exactly three kinds of shapes and there will not be a fourth." And that is exactly the typical situation when modeling a domain.

Why it was introduced. Two reasons. First, so an API's author could control extensibility of a type. Second — and this turned out to matter more — so the compiler would know the full set of variants and could check pattern-matching completeness.

// Before
interface Shape {}
// Anyone, anywhere, could write:
class Triangle implements Shape {}

// After
sealed interface Shape permits Circle, Rectangle {}

final class Circle implements Shape {
    // ...
}
final class Rectangle implements Shape {
    // ...
}

Three modifiers, one system:

ModifierMeaning
sealedOnly the subtypes listed in permits
finalNo subtypes at all
non-sealedThe hierarchy is reopened at this point

Every direct subtype of a sealed type must declare one of these three modifiers. That is a syntax requirement — there is no way to "forget" it.

sealed interface Shape permits Circle, Rectangle, Polygon {}

final class Circle implements Shape {}
record Rectangle(double w, double h) implements Shape {}  // a record is implicitly final
non-sealed class Polygon implements Shape {}               // this branch is open again

Accessibility rules: every permitted subtype must be in the same module (or, without modules, the same package) as the sealed type. Without that the compiler would have no way to guarantee it knows the full list.

The permits clause can be omitted if all subtypes are in the same source file:

sealed interface Shape {
    record Circle(double radius) implements Shape {}
    record Rectangle(double w, double h) implements Shape {}
}

What sealed gives you: an explicit, controlled, compiler-known set of direct subtypes. On its own it is a moderately useful documentation mechanism. Its real value only shows up combined with pattern matching — which is exactly what it was built for.

Java 17–21: Pattern Matching for switch

Status: PREVIEW (Java 17, JEP 406) → PREVIEW (18, 19, 20) → FINAL (Java 21, JEP 441)

Four preview rounds. That is a record — and a measure of how hard it was to nail down the semantics.

Problem. switch worked on int, String, and enums. Branching by type required an if-else-if ladder with instanceof and casts — with no completeness checking at all.

// Before
if (value instanceof Integer) {
    Integer i = (Integer) value;
    return "integer: " + i;
} else if (value instanceof String) {
    String s = (String) value;
    return "string: " + s;
} else {
    return "other";
}

// After
return switch (value) {
    case Integer i -> "integer: " + i;
    case String s  -> "string: " + s;
    case null      -> "null";
    default        -> "other";
};

Four things worth going through:

1. Type patterns as labels. case Integer i is the same pattern as in instanceof. The bound variable is visible in the branch body.

2. Handling null — a change that surprises people. A classic switch threw NullPointerException on null. That semantics was kept for compatibility:

switch (value) {
    case Integer i -> ...;
    default        -> ...;
}
// value == null  ->  NullPointerException, even though default is present

Only an explicit case null changes this behavior:

switch (value) {
    case null      -> "no value";
    case Integer i -> ...;
    default        -> ...;
}

You can also write case null, default -> .... Practical conclusion: default does not catch null. This is one of the most frequently overlooked rules in all of pattern matching.

3. Exhaustiveness. A switch used as an expression must cover every case. For type Object that means a default is required.

4. Combined with sealed — and here the system closes the loop:

sealed interface Shape permits Circle, Rectangle {}

double area(Shape shape) {
    return switch (shape) {
        case Circle c    -> Math.PI * c.radius() * c.radius();
        case Rectangle r -> r.w() * r.h();
    };   // OK — no default, the compiler knows these are all the variants
}

Adding Triangle to permits causes a compile error everywhere the switch does not handle the new variant. Not a default that quietly returns the wrong value. An error, here and now.

This is exactly the moment where sealed, record, and pattern matching stop being three separate features and become one tool.

when guards

Status: FINAL (Java 21) — the when syntax was introduced in the third preview round (Java 19)

Problem. A type alone does not always distinguish a case. An extra condition inside a branch broke completeness checking and forced logic to be duplicated.

return switch (value) {
    case Integer i when i > 0  -> "positive";
    case Integer i when i == 0 -> "zero";
    case Integer i              -> "negative";
    default                     -> "other";
};

The division of responsibility is strict: the pattern describes structure and type, the guard describes an extra condition on values. That split is not cosmetic — the compiler analyzes completeness based on patterns alone, entirely ignoring guards. Hence:

sealed interface Shape permits Circle, Rectangle {}

// does not compile — guards do not count toward exhaustiveness
return switch (shape) {
    case Circle c when c.radius() > 0 -> ...;
    case Rectangle r                  -> ...;
};

The order of case labels matters: matching goes top to bottom, and the compiler rejects a branch that is dominated by an earlier one.

Java 19–21: Record Patterns

Status: PREVIEW (Java 19, JEP 405) → PREVIEW (Java 20, JEP 432) → FINAL (Java 21, JEP 440)

Problem. Once a record was matched, its components still had to be pulled out manually through accessors. The structure of the data was known to the compiler, and yet it was unpacked by hand.

// Before
if (obj instanceof Point point) {
    int x = point.x();
    int y = point.y();
    System.out.println(x + y);
}

// After
if (obj instanceof Point(int x, int y)) {
    System.out.println(x + y);
}

Why it was introduced. Destructuring. Since a record declares its structure in its header, that same structure should be writable at the match site — symmetrically with construction.

Nested patterns. This is where it becomes genuinely useful:

record Point(int x, int y) {}
record Line(Point start, Point end) {}

if (obj instanceof Line(Point(int x1, int y1), Point(int x2, int y2))) {
    double length = Math.hypot(x2 - x1, y2 - y1);
}

A single expression replaces: a type test, a cast, two accessor calls, two more type tests, and four more accessors.

In record patterns you can use var when the component type is obvious:

case Line(Point(var x1, var y1), Point(var x2, var y2)) -> ...

Combined with a sealed hierarchy and a switch, this produces expressions that would have been a page of code in Java 8:

sealed interface Shape permits Circle, Rectangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double w, double h) implements Shape {}

double area(Shape shape) {
    return switch (shape) {
        case Circle(double r)               -> Math.PI * r * r;
        case Rectangle(double w, double h)  -> w * h;
    };
}

Formally, the JLS today distinguishes two kinds of patterns: type patterns and record patterns. The first test a type and bind the whole value; the second additionally decompose it into components. Record patterns are recursive — a pattern's component can itself be another pattern.

Java 21–22: Unnamed patterns and unnamed variables

Status: PREVIEW (Java 21, JEP 443) → FINAL (Java 22, JEP 456)

This is a good moment to note something easy to miss: this feature is not final in Java 21, even though many roundups lump it into the "what's new in Java 21" bucket. In Java 21 it requires --enable-preview. It only becomes final in Java 22.

Problem. Sometimes a pattern must match a component, but its value is irrelevant. A forced variable name is noise, and names like ignored, unused, or _unused are a workaround, not a solution.

// Unnamed pattern variable
if (value instanceof String _) {
    System.out.println("this is a string");
}

// In a record pattern — we only care about x
case Point(int x, int _) -> handle(x);

// Skipping a whole component
case Line(Point start, _) -> handleStart(start);

Unnamed variables also work outside pattern matching:

// An exception we do not use
try {
    parse(input);
} catch (NumberFormatException _) {
    recover();
}

// A loop variable
for (var _ : items) {
    counter++;
}

// Try-with-resources, where only the side effect matters
try (var _ = lock.acquire()) {
    doWork();
}

// An unused local variable with a side-effecting initializer
var _ = queue.poll();

_ (underscore) stopped being a legal identifier name back in Java 9 — precisely to reserve it for this occasion. Eight versions of preparation for one character.

Java 23–25: Module Import Declarations

Status: PREVIEW (Java 23, JEP 476) → PREVIEW (Java 24, JEP 494) → FINAL (Java 25, JEP 511)

Problem. Even a simple program needed a dozen-plus imports. In educational files and scripts, the import block could be longer than the actual logic.

// Before
import java.util.List;
import java.util.Map;
import java.util.HashMap;
import java.util.function.Function;
import java.util.stream.Collectors;

// After
import module java.base;

This is not import * for the whole JDK. The semantics is tightly tied to the module system:

  • import module M imports every public top-level type from every package the module M exports — unconditionally, i.e. without exports ... to.
  • The import is transitive: it also imports packages exported by modules that M requires as requires transitive. That is why import module java.se gives access to the entire Java SE API.
  • Name conflicts (java.util.List vs. java.awt.List) are a compile error at the point of use, not something silently resolved. They are solved with an ordinary single-type import, which takes priority:
import module java.base;
import module java.desktop;
import java.util.List;      // resolves the conflict

The feature is intended mainly for prototyping, learning, and scripts. In large codebases, explicit imports still carry dependency information — and tooling still relies on them.

Java 22–25: Flexible Constructor Bodies

Status: PREVIEW (Java 22, JEP 447) → PREVIEW (Java 23, JEP 482) → PREVIEW (Java 24, JEP 492) → FINAL (Java 25, JEP 513)

Problem. super(...) or this(...) had to be the first statement in a constructor. Consequence: arguments could not be validated or normalized before being passed to the superclass. The workaround was artificial static methods:

// Before
class Child extends Parent {
    Child(String value) {
        super(normalize(value));   // everything must fit in one expression
    }

    private static String normalize(String v) {
        var trimmed = v.trim();
        if (trimmed.isEmpty()) throw new IllegalArgumentException();
        return trimmed;
    }
}

// After
class Child extends Parent {
    Child(String value) {
        value = value.trim();
        if (value.isEmpty()) {
            throw new IllegalArgumentException("empty value");
        }

        super(value);
    }
}

Early construction context — this concept needs to be understood precisely, because the safety of the whole construct depends on it.

Code before super(...) runs at a moment when the object physically exists but is not yet initialized — the superclass fields hold their default values. That is why this context enforces restrictions:

Not allowedReason
Reading instance fields (own or inherited)They are not initialized yet
Calling instance methodsThey could read uninitialized state
Using this as a valueLeaking an unfinished object
Referencing the outer class via Outer.thisSame risk
Allowed
Calling static methodsThey do not touch instance state
Operating on parameters and local variables
Assigning to your own class's fieldsA write is safe, a read is not
Throwing exceptions, loops, conditionals

The last row of the first table and the third row of the second together produce an interesting asymmetry: before super() you are allowed to write to your own field, but not to read it.

This also solves the old problem of calling overridable methods from a superclass constructor:

class Child extends Parent {
    private final int value;

    Child(int value) {
        this.value = value;   // assignment BEFORE super() — allowed
        super();              // if Parent calls a method overridden in Child,
                               // value will already be set
    }
}

Java 23–25: Primitive Types in Patterns, instanceof, and switch

Status: PREVIEW (Java 23, JEP 455) → PREVIEW (Java 24, JEP 488) → PREVIEW (Java 25, JEP 507)

warning

This is a preview feature in Java 25 — it requires --enable-preview and may still change. It is not part of the stable language.

Problem. Pattern matching developed exclusively around reference types. Primitives required boxing, and switch on them only handled constant values. The system was visibly incomplete.

// The direction this is heading
switch (value) {
    case int i    -> "int: " + i;
    case long l   -> "long: " + l;
    case double d -> "double: " + d;
}

// instanceof on primitives
if (x instanceof int i) {
    // ...
}

Key concepts:

1. Exact conversion. case int i matches only when the value can be converted without any loss of information. That is a meaningful difference from casting:

long value = 3_000_000_000L;

if (value instanceof int i) {
    // does NOT match — the value does not fit in an int
}

int cast = (int) value;   // casting: a silent loss of data

This is exactly why this is a language feature, not sugar over casting: the semantics changes, not just the notation.

2. Widening vs. narrowing. Widening (int → long) is always lossless, so the match always succeeds. Narrowing (long → int, double → float) requires a runtime value check.

3. NullPointerException risk. With patterns on wrapper types combined with conversion to a primitive, unboxing can throw NPE — which is why the null-matching rules here matter and require explicit case null handling.

4. Primitive ↔ wrapper pattern relationship. case Integer i and case int i are not the same thing. The first tests a reference type, the second tests a primitive value with an exactness condition. The precise rules for their coexistence are one of the main reasons this feature has already gone through three preview rounds.

5. Exhaustiveness. In Java 25 (JEP 507), a restriction on label ordering in switches over primitives was lifted.

Java 21–25: Compact Source Files

Status: PREVIEW (Java 21, JEP 445) → PREVIEW (22, 23, 24) → FINAL (Java 25, JEP 512)

The feature changed its name three times: "Unnamed Classes" → "Implicitly Declared Classes" → "Compact Source Files". The renaming was not cosmetic — it reflected the evolution of the model.

Problem. A first Java program required understanding classes, access modifiers, static methods, arrays, and command-line arguments — before you could even print "Hello".

public class Main {
    public static void main(String[] args) {
        System.out.println("Hello");
    }
}

Of the six concepts in this snippet, a beginner needs exactly one.

Why it was introduced. Brian Goetz described this in the essay Paving the on-ramp: Java has a steep entry threshold not because it is hard, but because it forces constructs designed for large systems onto even the very first program.

// After — the entire file content
void main() {
    System.out.println("Hello");
}

What happens under the hood. The class still exists — it is implicit. The Java 25 specification defines the compact compilation unit and implicitly declared class:

  • A file without a top-level class declaration that contains a main method is compiled as an implicit class.
  • This class is final, extends Object directly, implements no interfaces, and lives in no named package.
  • The class name is derived from the file name, but is not available in code — it cannot be referred to or imported.
  • The file can contain fields and methods — they become members of the implicit class.
  • A compact source file automatically imports the java.base module — but not the static methods of java.lang.IO, which must still be called qualified as IO.println(...).
// The entire file — works in Java 25
String greeting = "Hi";

void main() {
    var names = List.of("Ada", "Grace");     // List, no import needed
    names.forEach(n -> IO.println(greeting + ", " + n));
}

Running without a separate compile step: java hello.java.

Java 25: Instance main methods

Status: FINAL (Java 25, JEP 512) — the same preview path since Java 21

Problem. public static void main(String[] args) is four concepts, none of which is needed to write a first program: an access modifier, staticness, arrays, arguments.

// Before
public static void main(String[] args) {
    System.out.println("Hello");
}

// After
void main() {
    System.out.println("Hello");
}

Available variants. The JVM picks a startup method by a fixed priority:

  1. static void main(String[] args) — the classic form
  2. static void main()
  3. void main(String[] args) — instance form with arguments
  4. void main() — instance form without arguments

The method does not need to be public — anything that is not private is enough. For the instance variants, the JVM creates an instance of the class using a no-arg constructor and then calls main. Hence the requirement: the class must have a no-argument constructor that is not private.

Importantly: the classic public static void main(String[] args) remains fully valid and is still the default entry point for production applications. Nothing has been removed or deprecated. This is a gentler on-ramp being added, not a change to the existing road.

Java 25: java.lang.IO

Status: FINAL (Java 25, JEP 512) — a companion API, not a language construct

Problem. Even after simplifying main, a simple program still needed System.out.println or a BufferedReader wrapped around an InputStreamReader wrapped around System.in. Console input was disproportionately complicated for the rest of the code's level.

void main() {
    String name = IO.readln("What is your name? ");
    IO.println("Hi, " + name);
}

The java.lang.IO class provides println, print, readln, and readln(String prompt). The class itself needs no import — it lives in java.lang — but its static methods are not implicitly imported into compact source files: they must still be called qualified, as IO.println(...), not println(...). This is a deliberate design choice, made precisely so that converting a compact file into an ordinary class does not silently change what an unqualified call resolves to.

Worth classifying honestly: this supports the new model for simple programs, it is not another fundamental language construct. It does not replace System.out or java.io in production code.

Cross-cutting: the preview-feature model

PREVIEW and FINAL markers appeared throughout this article. They deserve their own paragraph, because this is not a formal footnote — it is a change in how Java evolves at all.

The problem this solves. Language syntax is a permanent commitment. A construct, once shipped, cannot be changed without breaking compatibility. Before the preview era, a design mistake stayed forever — and Java carries a few such scars.

How it works (JEP 12):

  • A preview feature is fully implemented and tested, not a prototype.
  • It requires --enable-preview at both compile time and runtime.
  • Code compiled with preview in version N will not run on version N+1. The forced recompilation is a deliberate barrier against production use.
  • Every subsequent version can change the semantics based on feedback — or withdraw the feature entirely.

That this is not theoretical is shown by two cases. String Templates were in preview in Java 21 and 22, then were removed — the project went back to the drawing board. Unnamed Classes changed both name and model twice before stabilizing as Compact Source Files.

Paths of the features covered here:

FeatureFirst PreviewLater PreviewsFinal
Switch expressionsJava 12Java 13Java 14
Text BlocksJava 13Java 14Java 15
Pattern matching for instanceofJava 14Java 15Java 16
RecordsJava 14Java 15Java 16
Sealed classesJava 15Java 16Java 17
Pattern matching for switchJava 17Java 18, 19, 20Java 21
Record patternsJava 19Java 20Java 21
Unnamed patterns and variablesJava 21Java 22
Flexible constructor bodiesJava 22Java 23, 24Java 25
Module import declarationsJava 23Java 24Java 25
Compact source files + instance mainJava 21Java 22, 23, 24Java 25
Primitive types in patternsJava 23Java 24, 25still preview
String templatesJava 21Java 22withdrawn

The practical consequence for the reader: "it is in Java 21" and "it is final in Java 21" are two different things. Unnamed variables are in Java 21 as preview and final only in 22. Primitive patterns are in Java 25 — but behind a flag.

One domain model across seventeen versions

Instead of a feature catalog — one example, rewritten across successive generations. This is the best illustration of the thesis that every construct solves a concrete problem left by the previous one.

Java 8

interface Shape {}

final class Circle implements Shape {
    private final double radius;

    Circle(double radius) {
        this.radius = radius;
    }

    public double getRadius() {
        return radius;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Circle)) return false;
        Circle other = (Circle) o;
        return Double.compare(radius, other.radius) == 0;
    }

    @Override
    public int hashCode() {
        return Double.hashCode(radius);
    }

    @Override
    public String toString() {
        return "Circle[radius=" + radius + "]";
    }
}

// Rectangle — another 30 lines of the same

class AreaCalculator {
    double area(Shape shape) {
        if (shape instanceof Circle) {
            Circle c = (Circle) shape;
            return Math.PI * c.getRadius() * c.getRadius();
        } else if (shape instanceof Rectangle) {
            Rectangle r = (Rectangle) shape;
            return r.getWidth() * r.getHeight();
        }
        throw new IllegalArgumentException("Unknown shape: " + shape);
    }
}

Three problems, all invisible in the code:

  1. Anyone can add Triangle implements Shape — the compiler has no idea.
  2. Adding Triangle will not break area()'s compilation. It will break production.
  3. Sixty lines of code carrying two pieces of information: a circle has a radius, a rectangle has sides.

Java 16 — records remove the boilerplate

interface Shape {}

record Circle(double radius) implements Shape {}
record Rectangle(double width, double height) implements Shape {}

Sixty lines → three. Problem 3 solved. Problems 1 and 2 — still there.

Java 17 — sealed closes the hierarchy

sealed interface Shape permits Circle, Rectangle {}

record Circle(double radius) implements Shape {}
record Rectangle(double width, double height) implements Shape {}

Problem 1 solved: the set of variants is now part of the type, and the compiler knows it. Problem 2 — not yet, because there is no construct that puts that knowledge to use.

Java 21 — pattern matching closes the loop

double area(Shape shape) {
    return switch (shape) {
        case Circle(double r)                  -> Math.PI * r * r;
        case Rectangle(double w, double h)     -> w * h;
    };
}

Problem 2 solved. No default, no exception thrown at the end, no casts. Adding Triangle to permits causes a compile error right here.

Java 25 — a complete, runnable program

sealed interface Shape permits Circle, Rectangle {}

record Circle(double radius) implements Shape {
    Circle {
        if (radius <= 0) throw new IllegalArgumentException("radius must be positive");
    }
}

record Rectangle(double width, double height) implements Shape {}

double area(Shape shape) {
    return switch (shape) {
        case Circle(double r)              -> Math.PI * r * r;
        case Rectangle(double w, double h) -> w * h;
    };
}

String describe(Shape shape) {
    return switch (shape) {
        case Circle(double r) when r > 100     -> "a huge circle";
        case Circle(double r)                  -> "a circle with radius " + r;
        case Rectangle(double w, double h) when w == h -> "a square with side " + w;
        case Rectangle(double w, double h)     -> "a " + w + "x" + h + " rectangle";
    };
}

void main() {
    List.of(new Circle(2), new Rectangle(3, 3), new Circle(150))
        .forEach(s -> IO.println(describe(s) + " -> area " + area(s)));
}

That is the entire file. No enclosing class, no imports, no public static void main. Run with: java shapes.java.

Compare the numbers: the Java 8 version is around 80 lines, of which maybe 12 carried information. The Java 25 version is around 30 lines, and all of them carry information. And the second one is safer — the compiler will catch a missing case that the first version would never have noticed.

Philosophy: what actually changed

Java 8:

classes
+ interfaces
+ inheritance
+ anonymous classes
+ manual casting
+ boilerplate
+ conventions (JavaBeans, getters, DTOs)

Java 25:

records
+ sealed hierarchies
+ patterns (type, record, primitive*)
+ switch expressions
+ type inference (var)
+ compact source files
+ declarative data modeling

The shift can be put in one sentence: from "how to perform an operation" toward "how to describe the data's structure and its possible cases."

In Java 8 a domain model was expressed through convention: a class with private fields and getters is a DTO, because that is what we agreed on. The compiler knew nothing about it. A type hierarchy was open because there was no way to express closing it. Branching by type was manual because there was no construct to express it.

In Java 25 the same information is part of the type. record tells the compiler "this is a data carrier". sealed says "these are all the variants". Pattern matching lets you operate on that knowledge, and exhaustiveness checking turns a class of runtime bugs into compile-time errors.

This is, in essence, an adaptation of algebraic data types — a sealed interface with records is a sum of products — into a language that had to keep full backward compatibility across thirty years. The last two items on the list (compact source files, var) point in a different direction: they lower the entry barrier and reduce noise. But the main axis is a single one.

It is also worth noting what Java did not do: it did not add properties, it did not add type-level null-safety, it did not add string interpolation, it did not add operator overloading. Each of those would have been popular. None of them fit the coherent plan traced here.

Summary table: Java 8 → Java 25

Ver.FeatureProblemSolutionOld syntaxNew syntaxStatus
8Lambda expressionsAnonymous classes for simple behaviorBehavior as a valuenew Runnable() { public void run() {...} }() -> {...}FINAL
8Functional interfacesA lambda needs a contractFormalizing a single abstract methodcustom interfaces + classes@FunctionalInterface, Function<T,R>FINAL
8Method referencesA lambda only delegatesA shorthand for an existing methodx -> foo(x)Foo::fooFINAL
8Default methodsA new method breaks implementationsInterface evolutioninterface A { void f(); }default void g() {...}FINAL
8Static interface methodsUtilities in separate classesHelpers next to the interfaceMathUtils.square(5)interface M { static int square(...) }FINAL
8Type annotationsAnnotations only on declarationsAnnotations on types@NonNull String s; (declaration)List<@NonNull String>FINAL
8Repeating annotationsOne annotation, onceRepeatable annotations@Roles({@Role("A"), @Role("B")})@Role("A") @Role("B")FINAL
8Stream API (API)Manual loops and mutationA declarative pipelinefor (...) { if (...) list.add(...) }.stream().filter().map().toList()FINAL
9Private interface methodsDuplication in default methodsPrivate helpers inside an interfaceduplicated codeprivate void log(...)FINAL
9Diamond for anonymous classNo inference with {}Extending type inferencenew ArrayList<String>() {}new ArrayList<>() {}FINAL
9Try-with-resourcesForced redeclarationUsing an effectively final variabletry (Reader r = reader)try (reader)FINAL
10varLong local typesLocal variable type inferenceMap<String,List<User>> m = new HashMap<>();var m = new HashMap<...>();FINAL
11var in lambdasNo annotations without full typesUnifying parameter style(String a, String b) -> ...(@NonNull var a, var b) -> ...FINAL
12→14Switch expressionsbreak, fall-through, mutationswitch as an expressionswitch (x) { case 1: ... break; }var y = switch (x) { case 1 -> ...; };FINAL
13→14yieldA block must return a valueReturning from a switch blocka temporary variableyield value;FINAL
13→15Text Blocks\n, \", concatenationMulti-line literals"{\n \"a\": 1\n}"""" … """FINAL
14→16Pattern matching instanceofinstanceof + castType test + variable bindingif (o instanceof S) { S s = (S) o; }if (o instanceof S s)FINAL
14→16RecordsData-class boilerplateA declarative data carrierclass User { /* 40 lines */ }record User(String name) {}FINAL
14→16Compact constructorsA full constructor just for validationA validation shorthandUser(String n, int a) { ...; this.n = n; }User { if (a < 0) throw ...; }FINAL
15→17Sealed typesA hierarchy is always openA closed set of variantsinterface Shape {}sealed interface Shape permits A, B {}FINAL
17→21Pattern matching switchAn if-else ladder with castsswitch by typeif (v instanceof I) {...} else if ...case Integer i -> ...FINAL
19→21when guardsThe type alone is not enoughPattern + conditionif (v instanceof I i) { if (i > 0) ... }case Integer i when i > 0 -> ...FINAL
19→21Record patternsManual accessor callsDestructuringif (o instanceof Point p) { int x = p.x(); }if (o instanceof Point(int x, int y))FINAL
21→22Unnamed patterns / variablesArtificial names like ignoredExplicit omissioncatch (Exception ignored)catch (Exception _)FINAL
23→25Module importsMany single importsA module's whole API in one importimport java.util.List; ×10import module java.base;FINAL
22→25Flexible constructor bodiessuper() has to come firstCode before super()super(normalize(v));v = v.trim(); super(v);FINAL
21→25Compact source filesCeremony for simple programsAn implicit classpublic class Main { ... }just the code in the fileFINAL
21→25Instance main methodspublic static void main(String[])A simplified entry pointpublic static void main(String[] args)void main()FINAL
25java.lang.IO (API)I/O too heavy for simple programsSimple console I/OSystem.out.println(...)IO.println(...)FINAL
23→25Primitive types in patternsPattern matching without primitivesPatterns on primitivesif (o instanceof Integer i)case int i -> ...PREVIEW
21→22String templatesNo interpolationWITHDRAWN

The "Ver." column shows the path: 12→14 means "first preview in 12, final in 14".

Cheat sheet

// ── Java 8 ────────────────────────────────────────────────
x -> x * 2                                  // lambda
(a, b) -> a + b                             // lambda, multiple parameters
String::valueOf                             // method reference (static)
System.out::println                         // method reference (instance)
String::toUpperCase                         // method reference (arbitrary instance)
User::new                                   // constructor reference
default void stop() {}                      // default method
static int square(int x) { return x * x; }  // static interface method
List<@NonNull String> names;                // type annotation
@Role("A") @Role("B") class User {}         // repeating annotations
list.stream().filter(...).map(...).toList() // Stream API

// ── Java 9 ────────────────────────────────────────────────
private void log(String s) {}               // private interface method
new ArrayList<>() {}                        // diamond + anonymous class
try (reader) { ... }                        // try-with-resources, effectively final

// ── Java 10–11 ────────────────────────────────────────────
var user = new User();                      // local variable type inference
(var a, var b) -> a + b                     // var in lambda parameters
(@NonNull var a, var b) -> a + b            // with an annotation

// ── Java 14–15 ────────────────────────────────────────────
var d = switch (m) {                        // switch expression
    case 1, 3 -> 31;
    case 2 -> { yield compute(); }          // yield
    default -> 30;
};

String json = """
    { "name": "John" }
    """;                                    // text block

// ── Java 16–17 ────────────────────────────────────────────
obj instanceof User user                    // pattern matching for instanceof
record User(String name, int age) {}        // record
record User(String name) {                  // compact constructor
    User { name = name.trim(); }
}
sealed interface Result                     // sealed
    permits Success, Failure {}
non-sealed class Partial implements Result {}

// ── Java 21–22 ──────────────────────────────────────────────
switch (r) {
    case Success s -> ...;                  // type pattern in a switch
    case Success(String value) -> ...;      // record pattern
    case Failure f when f.retryable() -> ...;  // when guard
    case null -> ...;                       // explicit null handling
}
case Point(int x, int _) -> ...             // unnamed pattern
catch (Exception _) { recover(); }          // unnamed variable

// ── Java 25 ───────────────────────────────────────────────
import module java.base;                    // module import declaration

class Child extends Parent {                // flexible constructor body
    Child(String v) {
        v = v.trim();
        super(v);
    }
}

void main() {                               // compact source file
    IO.println("Hello");                    //   + instance main + java.lang.IO
}

// ── Preview in Java 25 (--enable-preview) ─────────────────
case int i -> ...                           // primitive type pattern

Summary

Seventeen versions, roughly thirty language changes, and not a single source-level backward-compatibility break. Code from Java 8 still compiles — people just don't write it that way anymore.