25:00
Focus
Lesson 1
~5 min50 XP

Modern Java Syntax and Records Overview

Introduction

Java has evolved dramatically since its early days of verbose boilerplate code. In this lesson, you'll master two of the most impactful modern Java features: local variable type inference with var (introduced in Java 10) and records (introduced as a preview in Java 14, finalized in Java 16). Together, these features let you write cleaner, more expressive code while maintaining Java's strong type safety — and understanding them deeply will change how you design your classes and methods.

Local Variable Type Inference with var

Starting with Java 10, the var keyword allows the compiler to infer the type of a local variable from its initializer expression. This is not dynamic typing — Java remains statically typed. The compiler simply determines the type at compile time so you don't have to write it explicitly.

Consider the difference:

// Before var
Map<String, List<Employee>> departmentMap = new HashMap<String, List<Employee>>();

// With var
var departmentMap = new HashMap<String, List<Employee>>();

Both lines produce identical bytecode. The variable departmentMap is still a HashMap<String, List<Employee>> — the compiler just figures that out for you.

Where You Can Use var

var works exclusively with local variables that have an initializer. This includes:

  • Local variables in methods and blocks: var name = "Alice";
  • Loop variables: for (var item : collection) { ... }
  • Index variables in for-loops: for (var i = 0; i < 10; i++) { ... }
  • Try-with-resources: try (var stream = new FileInputStream("data.txt")) { ... }

Where You Cannot Use var

This is where many developers trip up:

  • Method parameters: void process(var input) — won't compile
  • Method return types: var getData() — won't compile
  • Fields (instance or static): private var count = 0; — won't compile
  • Uninitialized variables: var x; — the compiler has nothing to infer from
  • Null initializers: var thing = null; — the compiler can't determine the type
  • Array initializers without a type: var arr = {1, 2, 3}; — ambiguous

Important: var is not a keyword in the traditional sense — it's a reserved type name. This means you can still have a variable named var (though you shouldn't), but you cannot create a class or interface named var.

The Readability Debate

Using var is a tradeoff. It reduces clutter when the type is obvious from context, but it can obscure meaning when the type isn't clear:

// Good — type is obvious from the right side
var customers = new ArrayList<Customer>();
var connection = DriverManager.getConnection(url);

// Bad — what type does this return?
var result = service.process(data);
var x = computeValue();

The guideline: use var when the type is evident from the initializer, and spell out the type when it aids comprehension.

Exercise 1Multiple Choice
Which of the following is a valid use of var in Java?

Understanding Java Records

Before Java 16, creating a simple data-carrying class required enormous amounts of boilerplate: a constructor, getters, equals(), hashCode(), and toString(). A record is a special kind of class designed specifically as a transparent carrier for immutable data. The compiler generates all that boilerplate for you.

public record Point(int x, int y) { }

That single line gives you:

  1. A private final field for each component (xx and yy)
  2. A canonical constructor that takes all components as parameters
  3. Accessor methods named x() and y() (not getX() / getY())
  4. A meaningful equals() that compares all components
  5. A meaningful hashCode() consistent with equals()
  6. A toString() that prints all component values: Point[x=3, y=7]

Records Are Implicitly Final

Records cannot be extended — they are implicitly final. They also implicitly extend java.lang.Record, which means they cannot extend any other class. However, records can implement interfaces:

public record TemperatureReading(double celsius, Instant timestamp) 
    implements Comparable<TemperatureReading> {
    
    @Override
    public int compareTo(TemperatureReading other) {
        return Double.compare(this.celsius, other.celsius);
    }
}

Immutability by Design

All fields in a record are final. There are no setter methods. Once a record instance is created, its state cannot change. This makes records excellent candidates for:

  • DTOs (Data Transfer Objects)
  • Value objects in domain-driven design
  • Keys in maps and elements in sets (stable hashCode())
  • Messages in concurrent programming (thread-safe by nature)
Exercise 2True or False
A Java record can extend an abstract class as long as the abstract class has no fields.

Customizing Records: Constructors and Methods

Records aren't just dumb data holders — you can customize them significantly while preserving their core guarantees.

The Compact Canonical Constructor

The most common customization is validation. Records support a special syntax called the compact canonical constructor, where you omit the parameter list and the field assignments (the compiler adds the assignments at the end automatically):

public record EmailAddress(String value) {
    public EmailAddress {   // No parentheses — compact form
        if (value == null || !value.contains("@")) {
            throw new IllegalArgumentException("Invalid email: " + value);
        }
        value = value.toLowerCase().strip();  // Normalizes before assignment
    }
}

In the compact form, you can reassign the parameter names (like value = value.toLowerCase()) and the compiler will assign those modified values to the final fields after your constructor body completes. This is different from a regular constructor, where you'd write this.value = value.toLowerCase().

Custom Constructors

You can define additional constructors, but they must delegate to the canonical constructor:

public record Range(int min, int max) {
    public Range {
        if (min > max) {
            throw new IllegalArgumentException("min must be <= max");
        }
    }
    
    // Custom constructor — must call canonical constructor
    public Range(int singleValue) {
        this(singleValue, singleValue);
    }
}

Adding Methods and Static Members

You're free to add instance methods, static methods, and static fields to records. You just cannot add instance fields — the record's state is defined entirely by its components:

public record Rectangle(double width, double height) {
    // Instance method
    public double area() {
        return width * height;
    }
    
    // Static factory method
    public static Rectangle square(double side) {
        return new Rectangle(side, side);
    }
    
    // Static field — allowed
    public static final Rectangle UNIT = new Rectangle(1, 1);
}

Note: You cannot add instance fields to a record beyond the components declared in its header. This restriction ensures that equals() and hashCode() always account for the complete state of the record.

Exercise 3Multiple Choice
What does the compact canonical constructor in a record allow you to do?

Records with Generics, Sealed Interfaces, and Pattern Matching

Records truly shine when combined with other modern Java features, creating expressive and type-safe domain models.

Generic Records

Records can be parameterized just like any other class:

public record Pair<A, B>(A first, B second) { }

var entry = new Pair<>("name", 42);
String key = entry.first();   // Type-safe, no casting
int value = entry.second();

This is incredibly useful for lightweight, ad-hoc return types without creating dedicated classes.

Records with Sealed Interfaces

Sealed interfaces (Java 17) restrict which classes can implement them. Combined with records, you can model algebraic data types — a concept from functional programming that Java now supports elegantly:

public sealed interface Shape permits Circle, Rectangle, Triangle {
    double area();
}

public record Circle(double radius) implements Shape {
    public double area() { return Math.PI * radius * radius; }
}

public record Rectangle(double width, double height) implements Shape {
    public double area() { return width * height; }
}

public record Triangle(double base, double height) implements Shape {
    public double area() { return 0.5 * base * height; }
}

The sealed keyword guarantees that Circle, Rectangle, and Triangle are the only implementations of Shape. The compiler knows this exhaustive list, which enables powerful pattern matching.

Pattern Matching with Records (Java 21)

Record patterns let you destructure a record directly in a switch expression or instanceof check:

public static String describe(Shape shape) {
    return switch (shape) {
        case Circle(var r) when r > 100 -> "Large circle with radius " + r;
        case Circle(var r)              -> "Circle with radius " + r;
        case Rectangle(var w, var h)    -> "Rectangle %sx%s".formatted(w, h);
        case Triangle(var b, var h)     -> "Triangle with base " + b;
    };
}

Notice how the compiler extracts the record components into local variables (r, w, h, b). Combined with the sealed interface, the compiler knows this switch is exhaustive — no default case is needed.

Common Pitfalls and Best Practices

Understanding when not to use these features is just as important as knowing how they work.

Pitfalls with var

1. Losing interface types: When you write var list = new ArrayList<String>(), the inferred type is ArrayList<String>, not List<String>. If you intended to program to the interface, you must spell it out:

List<String> list = new ArrayList<>();  // Preferred when interface matters
var list = new ArrayList<String>();     // Type is ArrayList, not List

2. Numeric literals and primitive widening: var count = 0; gives you int, not long or short. If you need a specific type, be explicit: var count = 0L; for long.

3. Ternary expression types: var x = condition ? 1 : 2.0; infers double because the compiler applies numeric promotion. This can be surprising if you expected int.

Pitfalls with Records

1. Mutable components: Records guarantee that their fields are final, but if a component is a mutable object (like a List), the contents of that object can still change:

public record Team(String name, List<String> members) { }

var team = new Team("Alpha", new ArrayList<>(List.of("Alice", "Bob")));
team.members().add("Charlie");  // This works! The list is mutated.

To prevent this, make defensive copies in the constructor and accessor:

public record Team(String name, List<String> members) {
    public Team {
        members = List.copyOf(members);  // Unmodifiable copy
    }
}

2. Records vs. entities: Records use all components for equals() and hashCode(). This makes them unsuitable for JPA entities, which typically use an id field for identity. Don't use records as Hibernate entities.

3. Serialization awareness: Records have special serialization behavior — they use the canonical constructor during deserialization, bypassing the usual ObjectInputStream mechanism. This is actually more secure, but it means you need to understand this distinction if you're working with Java serialization.

Exercise 4Fill in the Blank
To prevent mutation of a List component inside a record, you should make a ___ copy in the compact canonical constructor.

Key Takeaways

  • var enables local variable type inference — the compiler determines the type from the initializer, keeping Java statically typed while reducing verbosity. Use it when the type is obvious; spell it out when clarity matters.
  • Records are transparent, immutable data carriers that auto-generate constructors, accessors, equals(), hashCode(), and toString() — eliminating hundreds of lines of boilerplate.
  • The compact canonical constructor lets you add validation and normalization logic to records without manually assigning fields.
  • Records combine powerfully with sealed interfaces and pattern matching to model algebraic data types — enabling exhaustive, type-safe branching without instanceof chains or visitor patterns.
  • Watch for pitfalls: var can infer concrete types instead of interfaces, and records with mutable component types (like List) need defensive copies to maintain true immutability.
Check Your Understanding

Java 16 introduced records as a way to reduce boilerplate when creating classes whose primary purpose is to hold immutable data, while `var` (Java 10) allows the compiler to infer local variable types from their initializers. Imagine you are mentoring a junior developer who proposes refactoring an existing codebase by replacing ALL class declarations with records and replacing ALL explicit type declarations with `var`. Explain why this blanket approach would be problematic. In your response, describe at least two specific limitations of `var` (including where it cannot be used) and at least two scenarios where a record would be inappropriate compared to a traditional class. Finally, explain what principle should guide a developer in deciding when each feature is the right choice.

🔒Upgrade to submit written responses and get AI feedback
Go deeper
  • Why can't var be used for class fields?🔒
  • Can records implement interfaces in Java?🔒
  • How do records handle mutability and inheritance?🔒
  • Does var work with diamond operator alone?🔒
  • What happens when var infers an unexpected type?🔒