JEP draft: Enhanced Local Variable Declarations (Preview)

OwnerAngelos Bimpoudis
TypeFeature
ScopeSE
StatusSubmitted
Componentspecification / language
Discussionamber dash dev at openjdk dot org
EffortM
DurationS
Reviewed byAlex Buckley, Brian Goetz
Endorsed byBrian Goetz
Created2025/05/21 13:08
Updated2026/03/31 21:47
Issue8357464

Summary

Enhance local variable declarations to enable the extraction of multiple values from a single source value using pattern matching. This allows data-oriented computations to be expressed concisely and safely without added control flow. This is a preview language feature.

Goals

Non-Goals

Motivation

Declaring local variables is straightforward in Java, but it becomes inconvenient when you need to extract several related values from a single source value. For example, suppose you wish to compute the bounding box of a circle:

record Circle(Point center, double radius) {}
record Point(int x, int y) {}

void boundingBox(Circle c) {
    if (c != null) {                 // ┐
        Point ctr = c.center();      // │  laborious / mechanical:
        if (ctr != null) {           // │  - null guards
            int x = ctr.x(), y = ctr.y(); // │  - extraction with accessors
            double radius = c.radius();   // ┘

            int minX = (int) Math.floor(x - radius), maxX = (int) Math.ceil(x + radius);
            int minY = (int) Math.floor(y - radius), maxY = (int) Math.ceil(y + radius);
            ... use minX, maxX, etc ...
        }
    }
}

The code to extract data from the single source value c is laborious and dominates the actual computations: null checks and calls to accessor methods that force readers to scan through multiple lines are wrapped in conditionals which are further appearing nested to reach the main logic.

Pattern matching lets you extract multiple values from a single source value and initialize local variables without the labor. For example, the boundingBox method could be written as:

void boundingBox(Circle c) {
    if (c instanceof Circle(Point(int x, int y), double radius)) {
        int minX = ..., maxX = ...
        int minY = ..., maxY = ...
        ... use minX, maxX, etc ...
    }
}

This code tries to match c against a record pattern and, if the match succeeds, initializes the three local variables declared in the pattern all at once. The pattern extracts components from multiple objects starting at c: x and y from c's center Point, and radius from c itself.

However, patterns can currently only be used in conditional constructs: in instanceof, as shown above, and in the case labels of a switch, which causes the main logic to again be nested:

switch (c) {
    case Circle(Point(int x, int y), double radius) -> {
        int minX = ..., maxX = ...
        int minY = ..., maxY = ...
        ... use minX, maxX, etc ...
    }
}

Pattern Matching for switch (JEP 441) introduced the concept of an exhaustive set of patterns; i.e., whether it covers all (reasonable) cases of the selector. It would be convenient if a pattern that is exhaustive for the static type of the value c could be used in other, non-conditional constructs. This would let you declare and initialize multiple local variables at once in ordinary block code, without introducing additional control flow around the main computation.

We propose to enhance local variable declarations to allow the use of an exhaustive record pattern instead of a traditional variable declarator like int x. This means the boundingBox method can be written as:

void boundingBox(Circle c) {
    Circle(Point(int x, int y), double radius) = c;
    int minX = ..., maxX = ...
    int minY = ..., maxY = ...
    ... use minX, maxX, etc ...
}

The first line in the method is an enhanced local variable declaration statement. It matches c against the exhaustive record pattern on the left of =. The = operator in this enhanced local variable declaration statement performs pattern matching, not assignment. Because the pattern must be applicable and exhaustive for the static type of c, ordinary shape mismatches are ruled out statically; the remaining run-time failures are residual cases discussed below, which is exactly what you want when your code is asserting a strong invariant about the shape of the data at that program point.

Similarly, we propose to allow enhanced local variable declarations in the header of an enhanced for loop. This means the loop header can both range over a collection and extract the relevant values from each element:

for (Circle(Point(int x, int y), double radius) : circles) {
    ... x ... y ... radius ...
}

Description

An enhanced local variable declaration P [ = e ] introduces a record pattern that is required to be exhaustive for the static type of the value it is matched against, for the purpose of initializing the pattern variables contained in P. The declaration may contain an expression e to be pattern matched, or the expression is provided by the construct that the declaration appears in.

An enhanced local variable declaration declares the local variables in the pattern P and it initializes them by matching P against the value of the expression e.

An enhanced local variable declaration statement includes an enhanced local variable declaration and takes the following form: P = e ; where e always appears in the declaration and the declaration itself is followed by ;. This allows us to extract the state of an object into multiple variables in one operation:

Circle(Point center, double radius) = getCircle();
... center ... radius ...

An enhanced local variable declaration statement deconstructs a record instance on the right-hand side into its components. Record patterns can be nested to further deconstruct those components into their subcomponents:

Circle(Point(int x, int y), double radius) = getCircle();
... x ... y ... radius ...

You can use _ in the pattern to elide uninteresting components. As in other pattern contexts, var _ is also permitted; for example, if you only wanted the x coordinate of the center point:

Circle(Point(int x, var _), _) = getCircle();
... x ...

This is a preview language feature, disabled by default

To try out the changes described here, you must enable preview features:

Syntax and semantics

Enhanced local variable declarations are a new form of declaration and enhanced local variable declaration statements are a new kind of block statement in the Java grammar.

BlockStatement:
  LocalClassOrInterfaceDeclaration
  LocalVariableDeclarationStatement
  EnhancedLocalVariableDeclarationStatement
  Statement

EnhancedLocalVariableDeclarationStatement:
  EnhancedLocalVariableDeclaration ;

EnhancedLocalVariableDeclaration:
  RecordPattern [ = Expression ]

where:

RecordPattern:
  ReferenceType ( [ComponentPatternList] )

ComponentPatternList:
 ComponentPattern {, ComponentPattern }

ComponentPattern:
 Pattern
 _

An enhanced local variable declaration P [ = e ] captures the author's intent that the record pattern P is guaranteed to match the record instance from e. The rules for an enhanced local variable declaration P [ = e ] are as follows: (assuming record Point(int x, int y) {...} in examples)

The rules for an enhanced local variable declaration statement P = e; are as follows:

An enhanced local variable declaration statement is more powerful than a traditional local variable declaration statement: You can initialize multiple variables in a single local variable declaration statement, but each variable must supply its own initializer, e.g., int x = center.getX(), y = center.getY();. In contrast, an enhanced local variable declaration statement initializes multiple variables with values extracted from a single initializer, e.g., Point(int x, int y) = center;.

An enhanced local variable declaration statement also inherits the compositionality of patterns: instead of progressively extracting nested state with multiple statements, alternatively, you can use a nested record pattern to express the extraction in one declaration. For example:

Circle(Point(int x, int y), double radius) = c;

instead of:

Circle(Point center, double radius) = c;
assert center != null;
Point(int x, int y) = center;

Nested patterns do not have to fit on one physical line; when necessary, they can be formatted across multiple lines in the same way as other Java constructs.

Analogy between enhanced local variable declaration statements and pattern matching in switch

An enhanced local variable declaration statement P = e; can be thought of as equivalent to a switch statement with e as the selector and a switch block with a single case P:

switch (e) {
  case P: ...
}

because the rules for switch already require that:

For example,

Circle(Point center, double radius) = getCircle();

is legal for the same reason that this switch is legal:

switch (getCircle()) {
    case Circle(Point center, double radius) -> ... center ... radius ...
}

A switch with more than one case, or with one case and a default or a case null, has no analog with an enhanced local variable declaration statement.

Exceptions in the Enhanced Local Variable Declaration Statement

An enhanced local variable declaration statement differs from a traditional local variable declaration statement when the initializer to the right of = evaluates to null. In a traditional local variable declaration, the null value is assigned to the local variable:

Point p = getPoint();  // if getPoint() returns null, p is null

but in an enhanced local variable declaration statement, a NullPointerException is thrown because the statement performs pattern matching on the value of the initializer, and no record pattern matches null at the top level:

Point(int x, int y) = getPoint();  // if getPoint() returns null, throw NPE

If null is a valid result, the user can structure the code to handle it explicitly, and only perform the enhanced local variable declaration statement when a non-null value is present:

if (p == null) {
    // handle null case
} else {
   Point(int x, int y) = p;
   // use x, y
}

As with pattern matching in switch, an enhanced local variable declaration can still fail to match at run time because of remainder values. A set of remainder values contains values that are not matched by the pattern even though the statement was considered exhaustive at compile time. When the statement finishes executing without matching the value on the right-hand side because that value is in the remainder set, it throws MatchException. There are two such situations, plus a third in which the statement completes abruptly during deconstruction and also throws MatchException.

  1. When null is in the remainder set, due to a nested record pattern:

    Consider this statement, where the Circle instance on the right-hand side has a null center:

    Circle(Point(int x, int y), double r) = new Circle(null, 2.0);   // throws MatchException

    Nested record patterns like Point(int x, int y) always have null in their remainder set; they can never match null. As a result, the statement does not match the Circle instance and throws a MatchException since the enhanced local variable declaration finished executing without matching the value on the right-hand side.

    While nested record patterns never match null, some type patterns can, if it is proven at compile time that they will always match all possible run time values of that type. Consider the statement above, but with a type pattern Point p instead of a record pattern Point(int x, int y) :

    Circle(Point p, double r) = new Circle(null, 2.0);

    This statement does not throw an exception; the type pattern Point p can match all Point values of the corresponding component (including null). Consequently, the statement matches the right-hand side value and p will be initialized to null.

  2. When values of a new type are in the remainder set, due to a separate compilation issue:

    Remainder values can arise when sealed class hierarchies have evolved. Consider the method below which is compiled against the interface S, with only one permitted implementation:

    // library V1
     sealed interface S permits Only { }
     record Only(int v) implements S { }
    
     // user code compiled against V1
     static void m(S s) {
        Only(int v) = s;
        System.out.println(v);
     }

    If someone permits a second implementation later:

    // later library evolves to V2 without recompiling meth
     sealed interface S permits Only, Other {}
     record Only(int v) implements S { }
     record Other(int w) implements S {} // a new subtype

    but the method is not recompiled, then all values of type Other are added to the remainder set. Since the enhanced local variable declaration statement in the method does not match values of type Other, the statement throws MatchException, and the caller will have to deal with it:

    m(new Other(42));   // throws MatchException
  3. When deconstruction throws an exception during the extraction of data from the right-hand side.

    Assume the following record which overrides the accessor for the component x so that it does not return the component value.

    record Point(int x, int y) {
         @Override public int x() { throw new IllegalStateException("bad x"); }
     }

    Attempting to access the x component using a regular method invocation, throws the expected exception:

    Point exceptional = new Point(1, 2);
     exceptional.x(); // throws IllegalStateException

    Since the enhanced local variable declaration statement determines each record component by invoking the corresponding accessor method, if the accessor throws an exception, the statement throws a MatchException with e as the cause:

    Point exceptional = new Point(1, 2);
     Point(int x, int y) = exceptional; // throws MatchException

A unified LHS = RHS model and straightforward refactoring

A local variable declaration statement T x = e; is traditionally read as declaring one new local variable x of type T and initializing it from the value of e. If e evaluates to null, then x is initialized to null.

A local variable declaration and a type pattern have the same form -- T x -- and the same purpose: to declare one new variable initialized from some instance. Accordingly, a local variable declaration statement T x = e; can also be viewed as a switch with a single case whose top-level pattern is the type pattern T x:

switch (e) {
    case T x -> ...
}

This gives a useful unified mental model for statements of the form LHS = RHS: they initialize the declared variables from the value of the right-hand side, or they fail. One important exception is the treatment of null. A local variable declaration statement T x = e; still initializes x with null, while the corresponding switch statement would throw NullPointerException.

This JEP extends this reading of LHS = RHS by allowing the left-hand side to be not only a traditional local variable declaration but also richer pattern forms. In particular, an enhanced local variable declaration statement P(...) = e; can also be viewed as a switch statement with a single case whose top-level pattern is the record pattern P(...):

switch (e) {
    case P(...) -> ...
}

The record pattern P(...) goes one step further by extracting, recursively, the state of an instance into one or more new variables. Moreover, if e evaluates to null, then an enhanced local variable declaration statement P(...) = e; throws NullPointerException and does not initialize any of the pattern variables in P. This suggests a mental model that enhanced local variable declaration statements are a generalization of local variable declaration statements: instead of introducing one local name for one value, the left-hand side can introduce multiple local names for multiple values extracted from a single source value.

This generalization implies straightforward refactoring. Consequently, the traditional and enhanced forms should align wherever feasible. Without that alignment, a straightforward refactoring can make a cast appear or disappear merely because the left-hand side changed shape. Using sealed classes and interfaces (JEP 409) lets library authors expose a stable contract while retaining strict control over behavior and invariants. In particular, library authors can declare an abstract sealed class or a sealed interface for their public API and permit exactly one subclass: their own. It is a common programming pattern that is followed by the need to immediately "recover" that subclass in local code, typically via an explicit downcast. As an example, consider the sealed hierarchy:

sealed interface Foo permits FooImpl {
    String data();
}

record FooImpl(String data) implements Foo { }

Within the library, the library author will often write local variable declaration statements that downcast to the permitted subclass, such as:

public void libraryMethod(Foo f) {
    FooImpl fi = (FooImpl) f;
    String s = fi.data();
    ... s ...
}

This is boilerplate: it communicates an invariant already implied by the sealed hierarchy. Such explicit downcast is also error prone. If the sealed hierarchy is changed to support more permitted subtypes, such invariant is violated but the downcast would not fail to compile; the only failure signal would be a potential ClassCastException at run time.

Given the unified LHS = RHS model, it should also be possible to express the same operation with an enhanced local variable declaration statement, directly extracting the component:

public void libraryMethod(Foo f) {
   FooImpl(String s) = f;
   ... s ...
}

This is legal because the pattern FooImpl(String s) is exhaustive for Foo. In pattern matching, the fact that Foo is sealed with a single permitted implementation is given greater weight than the possibility that Foo might be changed independently to permit more implementations.

If FooImpl(String s) = f; is legal because FooImpl is exhaustive for Foo, then FooImpl fi = f; should also be legal when the programmer wants to name the whole value rather than immediately deconstruct it.

Without corresponding support for traditional local variable declarations, however, refactoring between the two forms would require the insertion or removal of a cast for no semantic reason. We therefore propose to strengthen the language's static checking by letting the compiler use sealed single-implementation information when checking such assignments: an instance which implements an interface can be assigned to a variable of a class which implements the interface, as long as the interface is sealed with only that class as the permitted implementation. This means the library author can use a local variable declaration statement without downcasting:

public void libraryMethod(Foo f) {
    FooImpl fi = f;    // previously:  (FooImpl) f
    ... fi.data() ...
}

This makes the ordinary form align better with exhaustive type patterns, and makes refactoring between naming the whole value and naming its components straightforward. It is also safer than the explicit cast, because if the sealed hierarchy later grows, recompilation of either form yields a compile-time error instead of leaving a stale cast in place:

public void libraryMethod(Foo f) {
    FooImpl fi = f;
    String s = fi.data();
    ... s ...
}

public void libraryMethod(Foo f) {
    FooImpl(String s) = f;
    ... s ...
}

If the declaration of sealed interface Foo is later changed to permit an additional implementation, such as:

sealed interface Foo permits FooImpl, BarImpl {
    String data();
}

then FooImpl(...) is no longer exhaustive for Foo, so recompiling the local variable declaration statement FooImpl fi = f; or the enhanced local variable declaration statement FooImpl(String s) = f; gives a compile-time error instead of leaving a stale explicit cast in place. If, instead of recompiling, you simply run the old class files as-is, the local variable declaration statement FooImpl fi = f; behaves as if the checked cast (FooImpl) f had been written explicitly and throws ClassCastException; it is not translated into switch or into an enhanced local variable declaration statement. The enhanced local variable declaration statement FooImpl(String s) = f; throws MatchException because the match candidate f refers to a remainder value.

The ability to assign a Foo expression to a FooImpl variable does not "lift" to array types: You cannot assign a Foo[] expression to a FooImpl[] variable even if Foo is a sealed interface with FooImpl as the sole, permitted implementation. For example, if fs has type Foo[], then FooImpl[] fis = fs; is illegal. The reason is that the JVM reifies array types and enforces array covariance: You cannot ever downcast a Foo[] object to FooImpl[], even if the elements of the Foo[] object are all FooImpl objects.

A consequence of strengthening assignment typing in this way is that downcasts from an interface or superclass to a single permitted implementation are no longer required in these locations:

This improvement also applies to classes that are abstract and sealed with just one subclass as the permitted implementation.

sealed abstract class A permits B {}
final class B extends A {}
void m(A a) {
    B b = a; // now legal
}

One limitation of this unified reading (apart from null as the RHS) arises with primitive patterns (see Dependencies for the proposal that adds primitive types in patterns, instanceof and switch). A local variable declaration statement T x = e; when T is a primitive type may not have an equivalent to a switch with a single case. A local variable declaration statement includes special constant-conversion rules that do not align with pattern matching's exhaustiveness. For example, byte b = 42; is allowed due to constant narrowing from a constant expression of int to byte since it is known statically that 42 is representable in byte's range. However, in the switch below, case byte b is not exhaustive for the 42 selector and a default is required:

switch (42) {     // error: not exhaustive
    case byte b -> {...}
}

Enhanced local variable declarations in enhanced for statements

Enhanced local variable declaration statements can be used in the body of an enhanced for statement, e.g.

for (Circle c : circles) {
    Circle(Point(int x, _), double radius) = c;
    ... x ... radius ...
}

The enhanced local variable declaration statement will often use the loop variable (c) on the right-hand side. This JEP proposes that enhanced local variable declarations can be used directly in the header of the loop:

for (Circle(Point(int x, _), double radius) : circles) {
    ... x ... radius ...
}

The grammar of the enhanced for statement is extended accordingly:

EnhancedForStatement:
  for ( LocalVariableDeclaration : Expression ) Statement
  for ( EnhancedLocalVariableDeclaration : Expression ) Statement

Integrating pattern matching in the enhanced for header supports direct, data-oriented extraction at the iteration point rather than introducing a new "loop element" name just to immediately take it apart with accessors. In the common case, any name for the loop element would be redundant: once the record pattern has bound the components of interest (and ignored those that are not), the element itself is no longer needed to express the computation.

If the enhanced local variable declaration is used in the header of an enhanced for statement, then the declaration is treated as if it had an expression e to be matched whose type is the iteration type of the enhanced for statement. This is not a filtering or partial-match mechanism; the same applicability and exhaustiveness requirements as for the statement form apply here.

An enhanced local variable declaration in the header of the enhanced for statement, just like in an enhanced local variable declaration statement, declares new local variables and cannot declare variables that shadow existing local variables.

At run time, execution of an enhanced for statement for (P : e) ... performs pattern matching on every loop iteration, matching P against successive values of e. The exceptions that may arise at run time are the same as in the enhanced local variable declaration statement.

As a result, the enhanced for statement that performs pattern matching, compared to a traditional enhanced for statement, is intolerant of null values produced by the Iterable expression: trying to match null will cause NPE to be thrown. If you wish to handle null values differently, e.g., by skipping them, you must explicitly check for null and then use an enhanced local variable declaration statement in the loop body:

for (Circle c : circles) {
    if (c != null) {
        Circle(Point(int x, _), double radius) = c;
        ... x ... radius ...
    }
}

Dependencies

Enhanced local variable declarations depend on the rules of applicability and exhaustiveness to ensure correctness. "Primitive Types in Patterns" (JEP 530) enhances these rules by allowing all types – primitive and reference – in type patterns in instanceof and switch. With the combination of these two preview features a user can have finer control over the nested patterns on the left-hand side:

record Point(int x, int y) {}
Point p = ...
Point(long x, long y) = p;

Alternatives

An alternative would be to introduce a new form of statement with a fresh keyword like let to denote a declaration involving a pattern, e.g.,

let Circle(...) = e;

Historically, rather than introducing new keywords for each new idiom, Java tends to grow by reusing and extending existing constructs at a higher level over time (pattern matching via instanceof and switch is a good example). In that spirit, we prefer to enhance local variable declarations statements instead of adding a new "let" statement.

A fresh keyword would also scale poorly to other places where local variable declarations appear today. For example, a new syntax would add visual noise and reduce readability in the enhanced for statement (e.g., for (let Circle(...) : circles) ...) and would complicate any future enhancement of local variable declarations in any of the other locations that is currently permitted.

Finally, we should plan for growth and consistency. By enhancing local variable declaration statements, we can also strengthen traditional local variable declarations by letting them benefit from the same exhaustiveness-based reasoning, which makes refactoring from old to new forms smoother. Introducing a new keyword for the new construct (and not strengthening the old) would create an avoidable asymmetry in the language.

Risks and Assumptions

A risk of adding enhanced local variable declaration statements to the Java language is that they appear to "overload” the = operator. Traditionally, = denotes initialization in local variable declarations (JLS 14.4.2) and assignment in expressions, field declarations, and annotation elements (JLS 15.26.1; e.g., @Foo(x=1, y="Hello")). In the enhanced local variable declaration statement, = denotes pattern matching. We expect confusion to be minimal for two reasons:

  1. The left-hand side of an enhanced local variable declaration is a record pattern, which is visually distinct from a single variable.

  2. Like a traditional local variable declaration, an enhanced local variable declaration has no expression form. Consequently, you cannot write bar(Point(int x, int y) = e); to declare and initialize x and y in the caller (just as you cannot write bar(int p = e); to declare a local variable p). By contrast, Java’s assignment operator produces an assignment expression (x = e) that assigns to an existing variable and can be embedded in larger expressions (e.g., foo(x = 5);); it can also appear as an expression statement (x = e;).

There is a risk that migrating code from a traditional local variable declaration statement to an enhanced local variable declaration statement can introduce a behavioral change for null values. The former can safely initialize a variable to null, whereas the latter performs pattern matching and will throw a NullPointerException if the right-hand side expression evaluates to null. For example, refactoring Point p = getPoint(); to Point(int x, int y) = getPoint(); changes behavior when getPoint() returns null, potentially causing previously working code paths to fail at run time. We assume IDEs and automated refactoring tools will help by suggesting appropriate guards to preserve intent.

Future Work

Having introduced enhanced local variable declarations in statements and in the header of enhanced for statements we may then consider the following: