Modern Java Features: var, Text Blocks, and Pattern Matching
Executive Summary
Modern Java features start with var. Defined in JEP 286, it infers the static type of local variables from their initializer, while the type system stays unchanged. Generics get shorter, and the rule of thumb is to use var when the right-hand side makes the type obvious and to spell types out when it does not. Next, text blocks (JEP 378) put multi-line strings in the source in the shape you want them in the output. Incidental indentation is stripped by a documented rule and formatting is handled by formatted, replacing years of concatenation noise.
Switch expressions then turn the old statement into a value-producing construct: arrow labels without fallthrough, yield or expression cases, and compiler-checked exhaustiveness over enums and sealed types. Finally, pattern matching completes the arc. instanceof patterns combine test and binding, type patterns in switch replace if-else casting chains, and record patterns destructure records in place. The payoff is code where the type structure drives control flow and the compiler verifies completeness. That is why the remaining articles of this course use these idioms by default.
var: Local Type Inference
var does not create dynamic typing. The variable gets exactly one static type, the type of its initializer, decided at compile time and never changed. What var removes is repetition:
// before: the generic type is spelled three times
Map<String, List<Book>> catalogByAuthor = new TreeMap<String, List<Book>>();
// after: declared once, at the initializer, where the information already lives
var catalogByAuthor = new TreeMap<String, List<Book>>();
The rules are narrow by design, and the compiler enforces every one:
| Rule | Example | Why |
|---|---|---|
| Local variables only | var x = 1; in a method works; var as a field, parameter, or return type is a compile error | Fields and signatures are contracts: they must stay explicit |
| Initializer required | var count; is a compile error | There is nothing to infer from |
| One declaration at a time | var a = 1, b = 2; is a compile error | Inference stays unambiguous |
| Inferred type is exact | var books = new ArrayList<Book>(); means ArrayList<Book>, not List<Book> | When you mean the interface, spell List<Book> books = new ArrayList<>() |
| Not a keyword | int var = 5; still compiles | Backward compatibility: existing code naming a variable var survives |
That last row is the kind of detail that tells you how carefully these language changes were designed. For instance, var is a reserved type name, not a keyword, so ten million lines of existing Java did not break the day it shipped.
When to use var is a style question with a working consensus. Use it when the right-hand side answers “what type is this” instantly: constructor calls, factory methods, casted results. In contrast, spell the type out when the initializer hides it, method calls with generic return types, collections assigned to interface types, empty containers. var is for readers, so the reader’s question, not the writer’s brevity, decides.
Text Blocks: Multi-Line Strings, Readably
Before text blocks, embedding JSON, SQL, or any formatted string in Java was a concatenation wall, and escaping made it worse:
// before: backslash noise, invisible structure, error-prone edits
String json = "{\n" +
" \"title\": \"" + title + "\",\n" +
" \"year\": " + year + "\n" +
"}";
// after: the shape you see is the string you get
String json = """
{
"title": "%s",
"year": %d
}""".formatted(title, year);
The mechanics are two rules. The opening triple-quote must be followed by a line terminator, so the content starts on the next line. Then the incidental indentation, the common whitespace the closing delimiter shares with content lines, is stripped. The compiler computes the minimal indentation across non-blank lines and removes exactly that, so how deeply the block sits in your method does not affect the output. Deliberate structure survives, deliberate escapes still work, \n and \” remain available. Also, the line-terminator escape \ keeps long lines in source and short in output.
Pair text blocks with formatted, String’s instance wrapper for String.format, and interpolation-style formatting reads cleanly. In fact, the JSON example above is the pattern the Part 9 HTTP articles use: template in a text block, values in one formatted call, no escaping maze.
Switch Expressions: From Statement to Value
Switch statements had three documented problems: fallthrough by default, forgetting break, and a statement that could not produce a value. That last one forced each case to return or assign separately. Switch expressions fix all three:
// before: statement, fallthrough traps, default required, every case must flow
String describe(BookFormat format) {
switch (format) {
case PAPERBACK:
return "paper";
case HARDBACK:
return "hard";
default:
return "unknown";
}
}
// after: an expression: it produces a value; no fallthrough, no default noise
String describe(BookFormat format) {
return switch (format) {
case PAPERBACK -> "paper";
case HARDBACK -> "hard";
};
}
The delta: the arrow form does not fall through, so the break-era bugs cannot happen. Also, the expression form returns a value, so the compiler checks every path produces one. Finally, with an enum whose constants are all covered, no default is required. As a result, adding a constant later turns this switch into a compile error until handled. That is the exhaustiveness property: the compiler finds the missing case instead of production finding it. When a case needs multiple statements, use yield:
return switch (format) {
case PAPERBACK -> "paper";
case HARDBACK -> {
var note = "hard";
yield note.toUpperCase(); // yields the case's value from a block
}
};
Pattern Matching: Type Tests That Bind, Switches That Analyze
Pattern matching arrived in two steps. The first, instanceof patterns, is small enough to look minor and removes an entire idiom:
// before: three steps, every time: test, cast, use
if (obj instanceof String) {
String s = (String) obj;
return s.length();
}
// after: test and bind in one step
if (obj instanceof String s) {
return s.length();
}
The pattern s combines the type test, the cast, and the binding variable. Moreover, s is only in scope where the test is guaranteed true, so the flow-typing and the scope agree. However, the second step, JEP 441, pattern matching for switch, is where the feature changes how code is structured. Combine it with the two tools this course built earlier: sealed interfaces that enumerate their subtypes, and records that expose their components:
sealed interface Shape permits Circle, Rectangle {}
record Circle(double radius) implements Shape {}
record Rectangle(double width, double height) implements Shape {}
double area(Shape shape) {
return switch (shape) {
case Circle(double radius) -> Math.PI * radius * radius;
case Rectangle(var w, var h) -> w * h;
};
}
Read case Circle(double radius) carefully, because it is doing four things at once. It is matching on the type, testing the record’s component (a nested pattern), binding radius from the record’s component, and replacing both the cast and the accessor call. Rectangle(var w, var h) mixes var into a record pattern, destructuring the same way. And the switch has no default for a reason that makes the whole feature worth it. The sealed interface says only Circle and Rectangle exist, so the compiler can see every permitted subtype is handled and enforces it. Add a Triangle record tomorrow and this switch stops compiling until you handle it. The missing-case bug moved from production to the compiler.
How Modern Java Features Fit: One Function, Two Eras
The individual features earn their keep; together they change what code looks like. The same function, written in the idiom most Java 8 codebases used and then in Java 21:
// before: casts, accessors, an if-else ladder, a runtime fallback
double area(Object 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();
} else {
throw new IllegalArgumentException("unknown: " + shape.getClass());
}
}
// after: sealed types, record destructuring, an exhaustive expression
double area(Shape shape) {
return switch (shape) {
case Circle(double radius) -> Math.PI * radius * radius;
case Rectangle(var w, var h) -> w * h;
};
}
Compare what the two versions trust. The first trusts the programmer to update the ladder when a new shape appears. Then the else throws whatever the programmer remembered to throw. By contrast, the second trusts the compiler. sealed lists the subtypes, the switch must cover them, and a new subtype is a compile error at every switch that needs updating. That is the modern Java bargain in one function: less ceremony. In addition, the correctness checks that used to be runtime exceptions become red squiggles before you commit.
How Real Systems Do This
Codebases on Java 17 and 21 adopted these features quickly because they are additive, opt-in per line, and remove code rather than add it. Switch expressions and instanceof patterns spread through business logic, and text blocks took over JSON, SQL, and help text. Meanwhile, sealed-plus-pattern-switch became the standard shape for type-driven domains, event kinds, message types, result variants.
Frameworks moved the same way. Spring’s web support exposes request routing and error handling over the same idioms. Similarly, modern libraries publish sealed interfaces specifically so your switches can be exhaustive over their results.
The var style war is a real thing I have mediated twice. One team banned var as “unreadable”. Another adopted it everywhere and produced lines like var result = process(input); where the type was genuinely unfindable without an IDE. Both positions were reacting to a real cost. The resolution that stuck, which this article’s rule encodes, was a review guideline rather than a ban or a mandate. Use var when the initializer states the type, explicit types when the reader would otherwise have to hunt for it. The arguments ended almost immediately, because both sides had been defending the same interest, reader comprehension, with opposite defaults. My own default after years of this is var for new-object constructions and the records I destructure. I keep explicit types on the API boundary of a method’s first lines, where the reader builds their mental model.
Decision Framework
- Does the initializer state the type in one glance, constructor call, factory, literal? Use var and let the reader skip the redundancy.
- Would the reader have to hunt for the type, generic factory method, empty container, interface-typed assignment? Spell the type out; explicitness is information here.
- Is the string more than two or three lines, JSON, SQL, templates, help text? Text block, paired with formatted for the values.
- Is a switch meant to produce a value? Switch expression, arrow labels, and let the compiler check exhaustiveness over enums and sealed types.
- Is the logic driven by which subtype you hold, event kinds, message types, result variants? Sealed hierarchy plus pattern switch, so adding a type fails every relevant switch at compile time.
- Are you destructuring a record whose components you use immediately? Record pattern in the case, replacing accessor calls and local variables.
When NOT to Use This
- Do not use var where the type is load-bearing for understanding. Examples include a method call with a generic return, a bare emptyList(), a long pipeline’s intermediate value. Brevity that costs a reader five seconds of navigation is a loss.
- Do not use text blocks for one-line strings or to “format” values into position. Instead, single-line concatenation and formatted remain the right tools at that size.
- Do not force pattern matching onto logic that is not type-shaped. A two-way boolean branch reads fine as if-else, and a switch over constants the compiler cannot enumerate needs a default anyway.
- Do not destructure records you use whole: case Rectangle(var w, var h) when you only call shape.width() twice adds noise. Instead, match the type and use the record accessor.
- Do not seal an interface just to get exhaustive switches when external code legitimately implements it. After all, sealed means closed, and that is an API decision, not a style one.
Common Mistakes
- Believing var is dynamic typing: the type is fixed at compile time. So assigning an incompatible value later is still a compile error.
- Trying var on fields, parameters, or return types: the compiler rejects it. That is correct, because those are contracts and inference would blur them.
- Expecting var to mean List: var books = new ArrayList<Book>() is ArrayList<Book>. So choose the interface explicitly when the abstraction matters.
- Counting text block indentation by eye instead of by rule: the stripped prefix is the minimal indentation of non-blank lines. Also, trailing spaces on a line are preserved even when invisible in review.
- Carrying default into an exhaustive switch “just in case”: over an enum or sealed type it is dead code that hides the compiler’s missing-case alarm. That alarm is exactly the protection you adopted the feature for.
- Writing record patterns with the wrong component count or types: the compiler checks the shape. In effect, that is the feature teaching you your own records.
- Nesting patterns five levels deep: destructuring exists to flatten one level of ceremony, not to build puzzles.
Key Takeaways
- var infers one static type from the initializer, for local variables only. Moreover, the reader’s instant comprehension, not the writer’s brevity, is the rule.
- Text blocks show multi-line strings in the shape they will be and strip common incidental indentation by a documented rule. They also pair with formatted for values.
- Switch expressions produce values, cannot fall through, and let the compiler verify exhaustiveness over enums and sealed types.
- instanceof patterns combine test, cast, and bind; pattern matching for switch turns type ladders into declarative case analysis.
- Record patterns destructure records in the case label, replacing casts and accessor calls, with var allowed inside.
- Sealed types plus exhaustive switches move the missing-case bug from production to the compiler, since adding a subtype fails every switch that needs updating.
- Together these features make type-driven logic shorter and machine-checked: the before-and-after area function is the whole argument in one screen.
FAQ
What is var in Java?
Local variable type inference, from JEP 286: the compiler infers the variable’s exact static type from its initializer. It works only for local variables with an initializer, never fields, parameters, or return types. Also, it does not make Java dynamically typed.
When should I not use var in Java?
When the initializer does not reveal the type at a glance: generic factory methods, empty collections, long chained calls. Spell the type out there, because var is for readers. A type the reader must hunt for defeats its purpose.
What are text blocks in Java?
Multi-line string literals between triple quotes, from JEP 378. The compiler strips the common incidental indentation so the string keeps the shape it shows in source. Escapes still work, which makes text blocks the standard way to embed JSON, SQL, and templates.
What is pattern matching for switch in Java?
From JEP 441: case labels can test types and destructure records, binding components directly. Combined with sealed types, the compiler can prove a switch handles every subtype. As a result, adding a new subtype produces a compile error at every switch that needs a new case.
What are record patterns in Java?
Record patterns match a record’s type and destructure its components in the case label, like case Circle(double radius). They replace the cast and the accessor calls. Components may use var, and the compiler checks the pattern against the record’s actual component list.
Conclusion
The modern Java features each removed a documented pain. var removed type repetition where it carried no information, and text blocks removed the concatenation wall. Similarly, switch expressions removed fallthrough and missing returns, and pattern matching removed the casting ladder while making completeness machine-checked. Still, none of them changed Java’s core deal of static typing and explicit contracts; they made that deal cheaper to honor.
The next article is a practice session: a small network client or file tool that exercises Part 4 end to end. It uses sockets or the HTTP client, records, pattern matching, text blocks, and the exception discipline around all of it, as one working program.
Adopt the idiom that removes the ceremony you actually have. The features that found no foothold on this tour, you will notice, are the ones that hid types rather than revealed them.
Last updated on 7 September 2026.
