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.
  • record classes, which give you data classes in one line.
  • sealed classes, 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>(); and var 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 up process to learn what result is.

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 final and can't extend another class (it already extends Record).
  • 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 own permits list.
  • 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:

  • final means no subclasses at all.
  • enum means a fixed set of objects (like MONDAY, TUESDAY), each created once.
  • sealed means a fixed set of types, and each type can have many objects, usually with different data (a Circle has a radius, and a Rectangle has 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.Source:https://dev.to/mayur_bodhare/java-var-records-and-sealed-classes-explained-simply-19nk

Related posts