Skip to main content

Command Palette

Search for a command to run...

Java 17 Features Every Senior Developer Should Know - Part 1: Introduction & var Keyword

Updated
13 min read
Java 17 Features Every Senior Developer Should Know - Part 1: Introduction & var Keyword

Welcome to the first installment of our comprehensive six-part series on Java 17 features! Whether you're a senior developer whose Java knowledge plateaued at version 8, a junior developer learning modern Java from scratch, or part of a team planning a migration to Java 17 LTS, this series is designed for you.


Welcome to the Series

Welcome to part one of "Java 17 Features Every Senior Developer Should Know"! Whether your Java knowledge stopped at version 8 or you're just starting your journey with the language, this series is for you. Over six articles, we'll explore the most important features introduced in Java 10-17, combining theory with practical examples and hands-on exercises.

What you'll learn in this series:

  • var keyword (Java 10) - Local variable type inference that eliminates repetitive type declarations

  • Records (Java 16) - Immutable data carriers with zero boilerplate for DTOs and value objects

  • Sealed Classes (Java 17) - Fine-grained control over class hierarchies and permitted subtypes

  • Pattern Matching (Java 16) - Smarter instanceof checks that eliminate redundant casts

  • Switch Expressions (Java 14) - Modern switch syntax with arrow notation and return values

  • Text Blocks (Java 15) - Multi-line string literals without escape character chaos

Each part includes runnable examples with JUnit tests. All code is in the GitHub repository—clone it and start experimenting right away!

Why Java 17?

Released in September 2021, Java 17 is the third LTS (Long-Term Support) release after Java 8 and 11. It's not just another version—it's the culmination of seven years of language evolution, bringing together features that were previewed and refined across Java 12-16.

Key features by version:

  • Java 10 (JEP 286) - var keyword for local variable type inference

  • Java 14 (JEP 361) - Switch expressions with arrow syntax and yield

  • Java 15 (JEP 378) - Text blocks for readable multi-line strings

  • Java 16 (JEP 394) - Pattern matching for instanceof

  • Java 16 (JEP 395) - Records as stable feature

  • Java 17 (JEP 409) - Sealed classes for controlled inheritance

Long-term support you can rely on: Oracle supports Java 17 until September 2029, giving teams a stable foundation for years to come.

Migration-friendly: Java 17 maintains backward compatibility with Java 8. You can migrate existing code without changes, then gradually adopt new features where they add value.

A strategic migration milestone:
Java 17 is the first LTS release to consolidate all major improvements from Java 9–17, making it the recommended bridge for teams moving from Java 8. Most modern frameworks (like Spring Boot 3.x and Jakarta EE 10+) now require Java 17 as a baseline. Upgrading to Java 17 not only brings immediate language and performance benefits, but also ensures a smoother, lower-risk path to future LTS versions like Java 21 and 25.


The var Keyword: Deep Dive

What is var?

The var keyword lets the compiler automatically infer the type of a local variable based on what you initialize it with. Introduced in Java 10 (JEP 286) and extended in Java 11 (JEP 323), var is Java's answer to one of its biggest criticisms: code verbosity.

Heads up! var is NOT dynamic typing like JavaScript or Python. The type is determined at compile-time and never changes. If you write var x = 42, the compiler says "this is an int" and from that moment on, x is an int forever. You can't later assign a String or anything else to x. This is static typing with inference, not dynamic typing.

What problem does var solve?

Before Java 10, we had to write the type twice on the same line:

// Before Java 10 - repeating ourselves
Map<String, List<Employee>> employeesByDept = new HashMap<String, List<Employee>>();

See that? Map<String, List<Employee>> appears on the left (variable declaration) and on the right (constructor call). The compiler knows the type from the right side—why do we have to repeat it? This isn't just annoying, it makes refactoring harder. Change List<Employee> to Set<Employee>? You have to update it in two places.

Other languages solved this ages ago. C++ got auto in 2011, C# had var since 2007, Scala and Kotlin have had type inference from day one. Java, focused on backward compatibility and explicit typing, needed more time. But by 2018, with the Stream API and increasingly complex generic types, Oracle finally gave in.

Why does var matter?

Look at these two code snippets:

// Without var - eyes glaze over at the type
Map<String, List<Transaction>> transactionsByCustomer = customers.stream()
    .collect(Collectors.groupingBy(
        Customer::getId,
        Collectors.mapping(Customer::getTransactions, Collectors.toList())
    ));

// With var - focus on the logic, not type ceremony
var transactionsByCustomer = customers.stream()
    .collect(Collectors.groupingBy(
        Customer::getId,
        Collectors.mapping(Customer::getTransactions, Collectors.toList())
    ));

In the second version, your attention goes to the logic: we're grouping customers by ID and mapping to their transactions. The type is still there—the compiler knows it—but it's not cluttering the code.

Another thing: refactoring. Changing ArrayList<String> to List<String> across your codebase? Without var, you update every declaration. With var? Most declarations stay unchanged because the type comes from the right side.

Stream API benefits from var too. Chains like .stream().map().filter().collect() create complex intermediate types you never use directly. Why write them out?

How does type inference work?

The compiler looks at the right side (the initializer) and infers the most specific type that fits. For var x = new ArrayList<String>(), the inferred type is ArrayList<String>, NOT List<String>. This can be a gotcha—if you later want to assign a LinkedList<String> to x, it won't work because x is typed as ArrayList<String>.

Type inference happens at compile time. The bytecode generated for var x = 42 is identical to int x = 42. At runtime there's no difference—var is pure syntactic sugar.

One more thing: Java doesn't automatically widen types. var x = 127 gives you int, not byte, even though 127 fits in a byte. var y = 3.14 is double, not float. If you need a specific primitive type, use an explicit declaration.

Where can you (and can't you) use var?

You CAN use var for:

  • Local variables (inside methods)

  • Loop variables (for, for-each)

  • Try-with-resources variables

You CANNOT use var for:

  • Class fields

  • Method parameters (except lambda parameters in Java 11+)

  • Method return types

This isn't an accident. Brian Goetz, Java's language architect, explained that var is meant to reduce local redundancy, not sacrifice API readability. Method signatures and class fields are public interfaces—they should be explicit. Local variables, used within a method's scope, benefit from inference because the context is immediate and limited.

When to use var (and when not to)?

Use var when:

  • The type is obvious from the right side: var user = new User("Alice")

  • The type is long and full of generics: var config = new HashMap<String, List<Connection>>()

  • You're working with Stream API chains where intermediate types don't matter

  • The variable name clearly suggests the type: var employeeCount = employees.size()

Don't use var when:

  • The initializer doesn't reveal the type: var data = fetchData() (what type is this?)

  • You need a supertype for flexibility: List<String> list = new ArrayList<>() (you can change implementations later)

  • You're using literals that give unexpected types: var b = 127 (that's int, not byte!)

  • The variable is used far from its declaration—explicit types help future readers


Basic Syntax and Use Cases

Where You CAN Use var

// 1. Local variables with initialization
var message = "Hello Java 17";
var count = 42;
var employees = new ArrayList<Employee>();

// 2. Enhanced for-loop
var numbers = List.of(1, 2, 3, 4, 5);
for (var num : numbers) {
    System.out.println(num);
}

// 3. Traditional for-loop
for (var i = 0; i < 10; i++) {
    // process
}

// 4. Try-with-resources
try (var reader = new BufferedReader(new FileReader("data.txt"))) {
    // read file
}

// 5. Lambda parameters (Java 11+)
Predicate<String> notEmpty = (@NonNull var s) -> !s.isEmpty();

Where You CANNOT Use var

// ❌ Class fields
public class Example {
    var field = "nope"; // Compilation error
}

// ❌ Method parameters
public void process(var param) { } // Error

// ❌ Method return types
public var getValue() { return 42; } // Error

// ❌ Without initialization
var x; // Error: cannot infer type

// ❌ Null initialization
var y = null; // Error: null has no type

// ❌ Lambda/method reference initializer
var comparator = String::compareToIgnoreCase; // Error

Practical Examples

Example 1: Collections and Stream API

Concept: var reduces boilerplate when working with complex collection types and Stream API operations.

// Demonstrates var with collections - eliminates verbose generic type declarations

public record Employee(String name, int age, String department) {}

public static void main(String[] args) {
    // Using var with List.of() - type is clearly List<Employee>
    var employees = List.of(
        new Employee("Alice", 30, "Engineering"),
        new Employee("Bob", 25, "Marketing"),
        new Employee("Charlie", 35, "Engineering")
    );

    // Example 1: Filtering - var eliminates List<Employee> repetition
    var seniors = employees.stream()
        .filter(e -> e.age() >= 28)
        .toList();
    seniors.forEach(System.out::println);

    // Example 2: Grouping - avoids verbose Map<String, List<Employee>>
    var byDepartment = employees.stream()
        .collect(Collectors.groupingBy(Employee::department));
    byDepartment.forEach((dept, emps) ->
        System.out.println(dept + ": " + emps));
}

Unit Test:

@Test
@DisplayName("Should filter employees by minimum age using var with Stream API")
void shouldFilterEmployeesByAge() {
    var employees = List.of(
        new Employee("Alice", 30, "Engineering"),
        new Employee("Bob", 25, "Marketing"),
        new Employee("Charlie", 35, "Engineering")
    );

    var result = employees.stream()
        .filter(e -> e.age() >= 28)
        .toList();

    assertEquals(2, result.size());
    assertTrue(result.stream().allMatch(e -> e.age() >= 28));
}

Output:

Employee[name=Alice, age=30, department=Engineering]
Employee[name=Charlie, age=35, department=Engineering]
Engineering: [Employee[name=Alice, age=30, department=Engineering], ...]
Marketing: [Employee[name=Bob, age=25, department=Marketing]]

Key Insight: var eliminates repeating verbose generic types like Map<String, List<Employee>> while maintaining full compile-time type safety.

Example 2: Anonymous Classes - Extended API Access

Concept: var enables access to custom methods in anonymous classes beyond the interface definition.

// Demonstrates var's power with anonymous classes
// Access custom methods that aren't part of the interface

public static void main(String[] args) {
    // Traditional approach: type is Comparator<String>
    // Limited to interface methods only
    Comparator<String> traditional = new Comparator<String>() {
        @Override
        public int compare(String s1, String s2) {
            return s2.length() - s1.length();
        }

        public String getDescription() {
            return "Sorts by length descending";
        }
    };
    // traditional.getDescription(); // ❌ Compilation error!

    // With var: full access to anonymous class API!
    var enhanced = new Comparator<String>() {
        @Override
        public int compare(String s1, String s2) {
            return s2.length() - s1.length();
        }

        public String getDescription() {
            return "Sorts by length descending";
        }
    };

    // ✅ Can call custom method!
    System.out.println(enhanced.getDescription());

    // Still works as Comparator
    var words = List.of("Java", "is", "awesome");
    var sorted = words.stream().sorted(enhanced).toList();
    System.out.println("Sorted: " + sorted);
}

Unit Test:

@Test
@DisplayName("Should enable access to custom methods in anonymous class through var")
void shouldAccessCustomMethodInAnonymousClass() {
    var comparator = new Comparator<String>() {
        public int compare(String s1, String s2) {
            return s2.length() - s1.length();
        }
        public String getDescription() {
            return "Length descending comparator";
        }
    };

    assertEquals("Length descending comparator", comparator.getDescription());
    assertTrue(comparator.compare("short", "verylongword") > 0);
}

Output:

Sorts by length descending
Sorted: [awesome, Java, is]

Key Insight: Without var, the type is Comparator<String>, limiting you to interface methods. With var, the compiler infers the full anonymous class type, enabling access to custom methods.

Example 3: Intersection Types - Unique Capability of var

What are intersection types? A unique capability of var that allows declaring a variable implementing multiple interfaces simultaneously using lambda expressions. This is impossible with explicit type declarations.

// Demonstrates var's unique capability with intersection types
// Lambda implementing BOTH Welcome AND Goodbye interfaces simultaneously

interface Welcome {
    String greet();
    default String getWelcomeMessage() {
        return "Welcome, " + greet() + "!";
    }
}

interface Goodbye {
    String greet();
    default String getFarewellMessage() {
        return "Goodbye, " + greet() + "!";
    }
}

public static void main(String[] args) {
    // var enables intersection type - IMPOSSIBLE with explicit types!
    var greeter = (Welcome & Goodbye) () -> "World";

    // Access methods from BOTH interfaces
    System.out.println(greeter.greet());              // "World"
    System.out.println(greeter.getWelcomeMessage());  // "Welcome, World!"
    System.out.println(greeter.getFarewellMessage()); // "Goodbye, World!"

    // Verify instance checks
    System.out.println(greeter instanceof Welcome);   // true
    System.out.println(greeter instanceof Goodbye);   // true
}

Unit Test:

@Test
@DisplayName("Should enable declaring variable with multiple interface types using var")
void shouldEnableIntersectionType() {
    var greeter = (Welcome & Goodbye) () -> "World";

    assertEquals("World", greeter.greet());
    assertEquals("Welcome, World!", greeter.getWelcomeMessage());
    assertEquals("Goodbye, World!", greeter.getFarewellMessage());
    assertTrue(greeter instanceof Welcome);
    assertTrue(greeter instanceof Goodbye);
}

Output:

World
Welcome, World!
Goodbye, World!
true
true

Key Insight: You cannot write Welcome & Goodbye greeter = ... with explicit types. This is a unique capability enabled only by var's type inference!


Common Pitfalls and Limitations

Pitfall 1: Type Too Specific

// ❌ PROBLEM: Type is ArrayList<String>, not List<String>
var specificList = new ArrayList<String>();
specificList.add("Java");

// Won't work - cannot assign LinkedList to ArrayList variable
// specificList = new LinkedList<>(); // ❌ Compilation error!

// ✅ SOLUTION: Use explicit interface type for flexibility
List<String> flexibleList = new ArrayList<>();
flexibleList = new LinkedList<>(); // ✅ Works

Pitfall 2: Diamond Operator

// ❌ Cannot use diamond without type info
// var list = new ArrayList<>(); // Compilation error

// ✅ Must specify type arguments
var list1 = new ArrayList<String>();

// ✅ Or use factory methods
var list2 = List.of("Java", "Python");

Pitfall 3: Primitive Type Widening

var byteValue = 127;    // Type is int, NOT byte!
var floatValue = 3.14;  // Type is double, NOT float!

// ❌ This fails:
// byte b = byteValue; // Error: incompatible types

// ✅ Use explicit types when size matters:
byte actualByte = 127;
float actualFloat = 3.14f;

Best Practices

DO use var when type is obvious:

var user = new User("Alice");
var count = users.size();

DO use var with long generic types:

var employeesByDept = new HashMap<String, List<Employee>>();

DON'T use var when type is unclear:

var data = fetchData(); // What type is this?

DON'T use var when you need flexibility:

var list = new ArrayList<String>(); // Too specific

Summary and Next Steps

Key Takeaways

  1. var is type inference, NOT dynamic typing - types are resolved at compile-time

  2. Use var for long generic types and stream chains to improve readability

  3. Avoid var when type isn't obvious from context

  4. Remember limitations: Only local variables, requires initialization

  5. Java's var is conservative compared to other languages - by design

Coming Up Next

Part 2: Records (JEP 395) - Data classes in Java 16

  • Eliminate boilerplate for DTOs

  • Canonical constructors and compact syntax

  • Records vs Lombok vs traditional POJOs

  • Immutability and pattern matching integration

Resources

Official Documentation

  • JEP 286 - Local-Variable Type Inference: openjdk.org/jeps/286 The official JEP (Java Enhancement Proposal) that introduced the var keyword in Java 10. This document provides comprehensive technical details about the design decisions, implementation specifics, and rationale behind local variable type inference. Essential reading for understanding why certain limitations exist (e.g., why var works only for local variables).

  • JEP 323 - Local-Variable Syntax for Lambda Parameters: openjdk.org/jeps/323 Extends var usage to lambda parameters (Java 11), enabling uniform syntax with annotations: (@NonNull var x) -> x.process(). This JEP explains how var maintains consistency with implicit lambda parameters while adding the ability to apply annotations.

Interactive References

  • Java Almanac - var Keyword: javaalmanac.io/features/var/ Interactive guide with runnable code examples showing var's capabilities and limitations. Includes visual timelines of when the feature was introduced and side-by-side comparisons with pre-Java 10 syntax. Perfect for quick reference and experimentation.

  • Java Almanac - Method References: javaalmanac.io/features/method-references/ Complements var knowledge by showing how method references interact with type inference. Understanding this relationship helps avoid common pitfalls when combining var with functional programming constructs.

Code Repository

  • GitHub Repository - blog-9mac-dev-code: github.com/dawid-swist/blog-9mac-dev-code Contains all working examples from this article series, including JUnit tests and Gradle build configurations. Clone this repository to run examples locally and experiment with the code:

      git clone https://github.com/dawid-swist/blog-9mac-dev-code.git
      cd blog-post-examples/java/2025-10-25-java17-features-every-senior-developer-should-know
      ../../gradlew test
    

Further Reading

  • Style Guidelines for Local-Variable Type Inference in Java: openjdk.org/projects/amber/guides/lvti-style-guide Official OpenJDK style guide for using var effectively. Covers best practices, anti-patterns, and real-world scenarios where var improves (or harms) code readability. Written by Brian Goetz and Stuart Marks, this guide is the authoritative source for var usage conventions.

  • Oracle Java SE Documentation - Local Variable Type Inference: docs.oracle.com/en/java/javase/17/language/local-variable-type-inference.html Oracle's official documentation including detailed syntax rules, restrictions, and compilation behavior. Useful for understanding edge cases and compiler error messages related to var.

Community Resources

  • Baeldung - Java 10 Local Variable Type Inference: Practical tutorial with common use cases and examples for Spring/JPA applications

  • InfoQ - Java 10 Released: Historical context and industry reaction to the var keyword introduction

  • Modern Java in Action (Book): Chapter 3 covers lambda expressions and type inference, showing how var fits into Java's modern functional programming paradigm

Run the Examples

All code examples from this article are available in the repository:

# Run individual examples
java -cp build/classes/java/main dev.nmac.blog.examples.java17.part1.VarCollectionsExample
java -cp build/classes/java/main dev.nmac.blog.examples.java17.part1.VarAnonymousClassExample
java -cp build/classes/java/main dev.nmac.blog.examples.java17.part1.VarIntersectionTypesExample
java -cp build/classes/java/main dev.nmac.blog.examples.java17.part1.VarLimitationsExample

# Run tests
./gradlew :blog-post-examples:java:2025-10-25-java17-features-every-senior-developer-should-know:test --tests "*part1*"

Written for blog.9mac.dev Part of the "Java 17 Features Every Senior Developer Should Know" series

Next: Part 2: Records

More from this blog

9mac.dev blog

12 posts