Java has a reputation for being wordy, and for a long time it earned it. To store a simple pair of numbers, I had to write a class with two fields, a constructor, two getters, equals, hashCode and toString. That's around forty lines for something that is just "x and y." And a line like this made my eyes tired:
Map<String, List<Integer>> scores = new HashMap<String, List<Integer>>();
Modern Java fixed a lot of this. In this post, we'll learn three features that make code shorter and safer:
-
var(local variable type inference), so you stop repeating types. -
recordclasses, which give you data classes in one line. -
sealedclasses, which let you say exactly which classes may extend yours.
At the end, we'll combine all three in one small program. If you read the post about streams, you'll notice that these features fit with them very well.
A quick note on versions, because each feature arrived at a different time: var came in Java 10, records are final in Java 16, sealed classes in Java 17, and the pattern-matching switch that I use with sealed types is final in Java 21. Any recent LTS version has all of them.
Unless I show a full class, the snippets go inside a main method. Records and other types are declared outside it.
Part 1: var (local variable type inference)
What is var?
var lets the compiler work out the type of a local variable from the value on the right side. This is called LVTI, short for Local Variable Type Inference.
var name = "Asha";
var count = 10;
var prices = new ArrayList<Double>();
var scores = new HashMap<String, List<Integer>>();
The long line from the start of this post becomes much shorter, because the type was already written on the right side. Why say it twice?
var is NOT dynamic typing
This is the most common misunderstanding. var doesn't make Java behave like JavaScript. The variable still has one fixed type, chosen at compile time:
var name = "Asha";
System.out.println(name.toUpperCase());
name = 5;
Output:
ASHA
and then a compile error on the last line:
incompatible types: int cannot be converted to String
name is a String forever. You can call String methods on it, and you can't put a number in it.
Behind the scenes
The compiler simply replaces var with the real type while compiling. The bytecode is exactly the same as if you had written the type yourself, so var has no cost at run time.
var count = 10; --> int count = 10;
var names = new ArrayList<String>(); --> ArrayList<String> names = new ArrayList<String>();
The type is worked out when you COMPILE. Nothing changes when the program RUNS.
Where you can use it
var works for local variables, in several places:
var message = "Hello"; // normal local variable
for (var i = 0; i < 3; i++) { } // classic for loop
for (var n : List.of(1, 2, 3)) { } // enhanced for loop
BiFunction<Integer, Integer, Integer> add =
(var a, var b) -> a + b; // lambda parameters (Java 11+)
Where you can't
var x; error: cannot infer type (no initial value)
var x = null; error: cannot infer type (the value is 'null')
var f = () -> 1; error: cannot infer type (lambda needs a target type)
var a = {1, 2, 3}; error: cannot infer type (array initializer needs a type)
Also NOT allowed: fields, method parameters and return types.
The compiler can only infer a type when the right side makes it clear. In all these cases, there's nothing (or not enough) to infer from. Also, var is only for local variables, so it can't be used for class fields or in method signatures.
A gotcha: the diamond
var list = new ArrayList<>();
list.add("a");
list.add(1);
System.out.println(list);
Output:
[a, 1]
With a normal declaration, List<String> list = new ArrayList<>(); tells the compiler to use String for the diamond <>. With var, there's no target type, so the compiler picks ArrayList<Object>, and the list accepts anything. Write new ArrayList<String>() when you use var.
Another small thing to know: var list = new ArrayList<String>(); has the type ArrayList<String>, not List<String>. If you want your variable to use the interface type (a good habit), write it out: List<String> list = new ArrayList<>();.
Is var a good idea?
It depends on readability:
-
Good: the type is obvious from the right side.
var users = new ArrayList<User>();andvar reader = new BufferedReader(...);need no extra explanation. -
Not good: the right side hides the type. In
var result = process(data);, a reader has to look upprocessto learn whatresultis.
A good rule: use var when it removes repetition, and use the explicit type when it adds information. And since the type is no longer visible, choose clear variable names.
Part 2: Records
The problem: so much code for plain data
Here is a simple class that just carries two numbers:
class PointClass {
private final int x;
private final int y;
PointClass(int x, int y) {
this.x = x;
this.y = y;
}
int getX() { return x; }
int getY() { return y; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof PointClass)) return false;
PointClass other = (PointClass) o;
return x == other.x && y == other.y;
}
@Override
public int hashCode() {
return Objects.hash(x, y);
}
@Override
public String toString() {
return "PointClass[x=" + x + ", y=" + y + "]";
}
}
That's a lot of text, and every line is boilerplate (code you write only because the language requires it). A record does the same in one line:
record Point(int x, int y) { }
Point p = new Point(3, 4);
System.out.println(p);
System.out.println(p.x());
System.out.println(p.equals(new Point(3, 4)));
System.out.println(p.hashCode() == new Point(3, 4).hashCode());
Output:
Point[x=3, y=4]
3
true
true
Two points with the same values are equal, even though they are different objects. Records compare by value, not by identity.
What the compiler writes for you
The list in the parentheses, (int x, int y), is called the record's components. For them, the compiler generates:
record Point(int x, int y) { }
|
| the compiler expands it into roughly this:
v
final class Point extends java.lang.Record {
private final int x;
private final int y;
Point(int x, int y) { ... } // the "canonical" constructor
int x() { return x; } // accessors: note, NO "get" prefix
int y() { return y; }
boolean equals(Object o) { ... } // compares all components
int hashCode() { ... }
String toString() { ... } // Point[x=3, y=4]
}
You can verify the hidden parts yourself:
System.out.println(Point.class.getSuperclass());
System.out.println(java.lang.reflect.Modifier.isFinal(Point.class.getModifiers()));
Output:
class java.lang.Record
true
So a record is a real class. It's final (nobody can extend it), and its parent is java.lang.Record. (Behind the scenes, the generated equals, hashCode and toString are wired up with the same invokedynamic mechanism we saw with lambdas.)
Validation with a compact constructor
What if you want to reject bad values? Write a compact constructor. It has the record's name but no parentheses. It runs before the fields are set:
record Age(int value) {
Age {
if (value < 0) {
throw new IllegalArgumentException("Age cannot be negative");
}
}
}
System.out.println(new Age(25));
try {
new Age(-1);
} catch (IllegalArgumentException e) {
System.out.println("Error: " + e.getMessage());
}
Output:
Age[value=25]
Error: Age cannot be negative
You can also adjust a value in the compact constructor by reassigning the parameter. And records can have their own methods and static helpers:
record Person(String name, int age) {
Person {
name = name.trim();
}
String greeting() {
return "Hi, " + name;
}
static Person of(String name) {
return new Person(name, 0);
}
}
System.out.println(new Person(" Asha ", 25));
System.out.println(Person.of("Ravi").greeting());
Output:
Person[name=Asha, age=25]
Hi, Ravi
The rules of records
- A record is implicitly
finaland can't extend another class (it already extendsRecord). - It can implement interfaces, and it can be generic, like
record Pair<A, B>(A first, B second). - The component fields are
private final. You can't add extra instance fields, and there are no setters. - You can add methods, static methods and static fields.
- Any extra constructor must call the canonical one with
this(...).
A trap: records are shallowly immutable
The fields can't be reassigned, but if a field holds a changeable object, that object can still change:
record Team(String name, List<String> members) { }
var members = new ArrayList<>(List.of("Asha"));
var team = new Team("Core", members);
members.add("Ravi");
System.out.println(team);
Output:
Team[name=Core, members=[Asha, Ravi]]
The team changed even though we never touched the record! The fix is a defensive copy in the compact constructor:
record SafeTeam(String name, List<String> members) {
SafeTeam {
members = List.copyOf(members);
}
}
var members = new ArrayList<>(List.of("Asha"));
var team = new SafeTeam("Core", members);
members.add("Ravi");
System.out.println(team);
Output:
SafeTeam[name=Core, members=[Asha]]
Now the record keeps its own unchangeable copy.
Records work nicely with streams
Because the accessors are plain methods, they fit method references:
record Student(String name, int marks) { }
List<Student> students = List.of(
new Student("Asha", 88),
new Student("Ravi", 72),
new Student("Meera", 95));
students.stream()
.filter(s -> s.marks() > 80)
.map(Student::name)
.forEach(System.out::println);
Output:
Asha
Meera
Records are ideal for small data carriers: results that return several values, map keys, DTOs (objects that carry data between layers) and test data.
Part 3: Sealed classes
The problem: inheritance is too open
Normally, if you write interface Shape, anyone can implement it, anywhere, at any time. That's flexible, but sometimes it's a problem. Imagine you want to say "a Shape is only a Circle or a Rectangle, nothing else." With normal inheritance, you can't say that, and the compiler can never be sure it has seen every possible shape.
A sealed class or interface solves this. It lists exactly which classes may extend or implement it, using the permits keyword:
sealed interface Shape permits Circle, Rectangle { }
record Circle(double radius) implements Shape { }
record Rectangle(double width, double height) implements Shape { }
Normal hierarchy: Shape <--- anyone can extend it (unknown subclasses)
Sealed hierarchy: Shape <--- ONLY Circle and Rectangle
the compiler knows ALL the cases
If another class tries to join the family uninvited:
final class Triangle implements Shape { }
class is not allowed to extend sealed class: Shape (as it is not listed in its 'permits' clause)
The three choices for a subclass
Every class that is permitted by a sealed class must declare what happens next in the family, with one of three keywords:
-
final: no more subclasses. The family ends here. -
sealed: it restricts its own subclasses again, with its ownpermitslist. -
non-sealed: it opens the hierarchy back up, so anyone may extend it.
sealed class Vehicle permits Car, Truck, Bike { }
final class Car extends Vehicle { }
sealed class Truck extends Vehicle permits Pickup { }
final class Pickup extends Truck { }
non-sealed class Bike extends Vehicle { }
Vehicle (sealed)
/ | \
Car Truck Bike
final sealed non-sealed
|
Pickup
final
Records are automatically final, which is why they pair so well with sealed interfaces. A few more rules:
- A permitted subclass must directly extend the sealed class.
- It must be in the same module (or the same package, if your project doesn't use modules).
- If all the subclasses are in the same file as the sealed class, you can leave out
permits, and the compiler works it out.
The payoff: exhaustive switch
Because the compiler knows every possible subtype, a switch over a sealed type doesn't need a default:
static double area(Shape shape) {
return switch (shape) {
case Circle c -> Math.PI * c.radius() * c.radius();
case Rectangle r -> r.width() * r.height();
};
}
System.out.println(area(new Circle(1)));
System.out.println(area(new Rectangle(3, 4)));
Output:
3.141592653589793
12.0
(This pattern-matching switch needs Java 21 or newer.) The best part comes later. Imagine you add a Triangle to the permits list but forget to update area. The code stops compiling:
the switch expression does not cover all possible input values
The compiler found the bug for you, long before a user would. That's the real value of sealed types: when the set of cases changes, every place that must handle them is flagged.
In Java 21 and newer, you can even take a record apart inside the case:
case Circle(double radius) -> Math.PI * radius * radius;
case Rectangle(double width, double height) -> width * height;
A practical example: success or failure
A common use is a result type that is either a success or a failure:
sealed interface Result permits Success, Failure { }
record Success(String data) implements Result { }
record Failure(String error) implements Result { }
static String describe(Result r) {
return switch (r) {
case Success s -> "OK: " + s.data();
case Failure f -> "Error: " + f.error();
};
}
System.out.println(describe(new Success("saved")));
System.out.println(describe(new Failure("timeout")));
Output:
OK: saved
Error: timeout
Behind the scenes
The permits list is stored inside the .class file. When a class is loaded, the JVM checks it, so the rule is enforced both by the compiler and at run time. You can even read it with reflection:
System.out.println(Shape.class.isSealed());
for (Class<?> c : Shape.class.getPermittedSubclasses()) {
System.out.println(c.getSimpleName());
}
Output:
true
Circle
Rectangle
Sealed vs enum vs final
These three often get confused:
-
finalmeans no subclasses at all. -
enummeans a fixed set of objects (likeMONDAY,TUESDAY), each created once. -
sealedmeans a fixed set of types, and each type can have many objects, usually with different data (aCirclehas a radius, and aRectanglehas a width and height).
Putting it all together
Let's use everything in one small program: a tiny calculator that models expressions as a sealed hierarchy of records, walks them with a switch, and uses var for the local variables.
sealed interface Expr permits Num, Add, Mul { }
record Num(int value) implements Expr { }
record Add(Expr left, Expr right) implements Expr { }
record Mul(Expr left, Expr right) implements Expr { }
public class Calculator {
static int eval(Expr e) {
return switch (e) {
case Num n -> n.value();
case Add a -> eval(a.left()) + eval(a.right());
case Mul m -> eval(m.left()) * eval(m.right());
};
}
public static void main(String[] args) {
var expr = new Add(new Num(2), new Mul(new Num(3), new Num(4)));
System.out.println(eval(expr));
System.out.println(expr);
}
}
Output:
14
Add[left=Num[value=2], right=Mul[left=Num[value=3], right=Num[value=4]]]
Look at how each feature helped:
-
Records gave us four tiny data types in four lines, and a readable
toStringfor free. -
Sealed told the compiler that
Expris onlyNum,AddorMul, soevalneeds nodefault. -
switchwith patterns unpacked each case without casts. -
varkept the long type name out ofmain.
Try adding a Sub record to the permits list, and watch the compiler point at eval.
Which one should I use?
-
varor the explicit type? Usevarwhen the right side already shows the type (new ArrayList<String>(), a constructor call, a clearly named factory). Use the explicit type when it helps the reader, or when you want an interface type likeList<String>. - Record or class? Use a record for plain, unchangeable data carriers: DTOs, map keys, multiple return values, test data. Use a class when you need changeable state, inheritance, hidden internal data or behavior that doesn't revolve around the data. Many frameworks that need changeable, proxied objects (such as JPA entities) don't fit records.
- Sealed, enum or open interface? Use an enum for a fixed set of constants. Use a sealed hierarchy for a fixed set of types that carry different data. Use a normal open interface when other people should be able to add their own implementations (a plugin system, for example).
- Records and sealed together? This is a great combination for modeling "one of several shapes of data," like results, events, commands and expressions.
Common mistakes
-
Using
varwhen the type becomes unclear. If a reader can't tell what the variable is without hunting, write the type. Also remembervar list = new ArrayList<>()gives youArrayList<Object>. -
Thinking
varmakes Java dynamic. The type is fixed at compile time. You can't assign a different type later, and you can't usevarfor fields, parameters or return types. - Assuming records are fully immutable. If a component is a list or another changeable object, it can still change. Make a defensive copy in the compact constructor, and remember that you can't add instance fields or setters.
-
Using the wrong accessor name. A record's accessor is
p.x(), notp.getX(). And equality is based on values, so two records with the same data areequals. -
Forgetting what a permitted subclass must say. Each one has to be
final,sealedornon-sealed, and it must sit in the same module (or package). Otherwise, the code won't compile. -
Adding a
defaultbranch to a switch over a sealed type. It silences the compiler's exhaustiveness check. When you add a new subtype later, it falls intodefaultwithout any warning. Leavedefaultout so the compiler can protect you.
Quick recap
-
varlets the compiler infer the type of a local variable. The type is still fixed at compile time, and the bytecode is the same as if you'd written it out. -
varneeds an initial value that shows the type. It can't be used for fields, parameters, return types,nullor lambdas without a target type. Use it when it removes repetition, not when it hides the type. - A record is a one-line data class. The compiler generates the final fields, canonical constructor, accessors like
x(), andequals,hashCodeandtoString. It's implicitlyfinal, extendsjava.lang.Recordand can't have extra instance fields. - Use a compact constructor for validation, and make a defensive copy of changeable components.
- A sealed class or interface lists its allowed subclasses with
permits. Each one must befinal,sealedornon-sealed. - Sealed types give you an exhaustive
switchwith nodefault, so the compiler tells you when you forget a case. - Records plus sealed interfaces plus pattern-matching
switchis a powerful modern-Java pattern.
Try it yourself
Here's a small practice idea: model a payment system. Create a sealed interface Payment permits Card, Upi, Cash, with a record for each, such as Card(String number, int amount), Upi(String id, int amount) and Cash(int amount). Write a method fee(Payment p) that returns a different fee for each, using a switch with no default. Use var for your local variables in main.
For a bonus experiment, add a new record NetBanking to the permits list, and read the compiler error on your switch. Then handle the new case and see it compile again.
If this helped, or if you spotted something I should fix, tell me in the comments. I'm still learning too.
That wraps up this two-part series on modern Java.
Top comments (0)