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.
varStarting 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.
varvar works exclusively with local variables that have an initializer. This includes:
var name = "Alice";for (var item : collection) { ... }for (var i = 0; i < 10; i++) { ... }try (var stream = new FileInputStream("data.txt")) { ... }varThis is where many developers trip up:
void process(var input) — won't compilevar getData() — won't compileprivate var count = 0; — won't compilevar x; — the compiler has nothing to infer fromvar thing = null; — the compiler can't determine the typevar arr = {1, 2, 3}; — ambiguousImportant:
varis not a keyword in the traditional sense — it's a reserved type name. This means you can still have a variable namedvar(though you shouldn't), but you cannot create a class or interface namedvar.
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.
var in Java?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:
private final field for each component ( and )x() and y() (not getX() / getY())equals() that compares all componentshashCode() consistent with equals()toString() that prints all component values: Point[x=3, y=7]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);
}
}
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:
hashCode())Records aren't just dumb data holders — you can customize them significantly while preserving their core guarantees.
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().
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);
}
}
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()andhashCode()always account for the complete state of the record.
Records truly shine when combined with other modern Java features, creating expressive and type-safe domain models.
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.
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.
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.
Understanding when not to use these features is just as important as knowing how they work.
var1. 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.
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.
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.equals(), hashCode(), and toString() — eliminating hundreds of lines of boilerplate.instanceof chains or visitor patterns.var can infer concrete types instead of interfaces, and records with mutable component types (like List) need defensive copies to maintain true immutability.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.