DEV Community

Mayur Bodhare
Mayur Bodhare

Posted on

Java var, Records and Sealed Classes Explained Simply

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>>();
Enter fullscreen mode Exit fullscreen mode

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>>();
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

Output:

ASHA
Enter fullscreen mode Exit fullscreen mode

and then a compile error on the last line:

incompatible types: int cannot be converted to String
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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+)
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

Output:

[a, 1]
Enter fullscreen mode Exit fullscreen mode

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 + "]";
    }
}
Enter fullscreen mode Exit fullscreen mode

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) { }
Enter fullscreen mode Exit fullscreen mode
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());
Enter fullscreen mode Exit fullscreen mode

Output:

Point[x=3, y=4]
3
true
true
Enter fullscreen mode Exit fullscreen mode

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]
   }
Enter fullscreen mode Exit fullscreen mode

You can verify the hidden parts yourself:

System.out.println(Point.class.getSuperclass());
System.out.println(java.lang.reflect.Modifier.isFinal(Point.class.getModifiers()));
Enter fullscreen mode Exit fullscreen mode

Output:

class java.lang.Record
true
Enter fullscreen mode Exit fullscreen mode

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");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode
System.out.println(new Age(25));

try {
    new Age(-1);
} catch (IllegalArgumentException e) {
    System.out.println("Error: " + e.getMessage());
}
Enter fullscreen mode Exit fullscreen mode

Output:

Age[value=25]
Error: Age cannot be negative
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode
System.out.println(new Person("  Asha ", 25));
System.out.println(Person.of("Ravi").greeting());
Enter fullscreen mode Exit fullscreen mode

Output:

Person[name=Asha, age=25]
Hi, Ravi
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

Output:

Team[name=Core, members=[Asha, Ravi]]
Enter fullscreen mode Exit fullscreen mode

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);
    }
}
Enter fullscreen mode Exit fullscreen mode
var members = new ArrayList<>(List.of("Asha"));
var team = new SafeTeam("Core", members);

members.add("Ravi");
System.out.println(team);
Enter fullscreen mode Exit fullscreen mode

Output:

SafeTeam[name=Core, members=[Asha]]
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

Output:

Asha
Meera
Enter fullscreen mode Exit fullscreen mode

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 { }
Enter fullscreen mode Exit fullscreen mode
   Normal hierarchy:    Shape  <---  anyone can extend it (unknown subclasses)

   Sealed hierarchy:    Shape  <---  ONLY Circle and Rectangle
                                     the compiler knows ALL the cases
Enter fullscreen mode Exit fullscreen mode

If another class tries to join the family uninvited:

final class Triangle implements Shape { }
Enter fullscreen mode Exit fullscreen mode
class is not allowed to extend sealed class: Shape (as it is not listed in its 'permits' clause)
Enter fullscreen mode Exit fullscreen mode

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 { }
Enter fullscreen mode Exit fullscreen mode
              Vehicle  (sealed)
             /    |     \
          Car   Truck    Bike
        final  sealed   non-sealed
                  |
               Pickup
                final
Enter fullscreen mode Exit fullscreen mode

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();
    };
}
Enter fullscreen mode Exit fullscreen mode
System.out.println(area(new Circle(1)));
System.out.println(area(new Rectangle(3, 4)));
Enter fullscreen mode Exit fullscreen mode

Output:

3.141592653589793
12.0
Enter fullscreen mode Exit fullscreen mode

(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
Enter fullscreen mode Exit fullscreen mode

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;
Enter fullscreen mode Exit fullscreen mode

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();
    };
}
Enter fullscreen mode Exit fullscreen mode
System.out.println(describe(new Success("saved")));
System.out.println(describe(new Failure("timeout")));
Enter fullscreen mode Exit fullscreen mode

Output:

OK: saved
Error: timeout
Enter fullscreen mode Exit fullscreen mode

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());
}
Enter fullscreen mode Exit fullscreen mode

Output:

true
Circle
Rectangle
Enter fullscreen mode Exit fullscreen mode

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.println(eval(expr));
        System.out.println(expr);
    }
}
Enter fullscreen mode Exit fullscreen mode

Output:

14
Add[left=Num[value=2], right=Mul[left=Num[value=3], right=Num[value=4]]]
Enter fullscreen mode Exit fullscreen mode

Look at how each feature helped:

  • Records gave us four tiny data types in four lines, and a readable toString for free.
  • Sealed told the compiler that Expr is only Num, Add or Mul, so eval needs no default.
  • switch with patterns unpacked each case without casts.
  • var kept the long type name out of main.

Try adding a Sub record to the permits list, and watch the compiler point at eval.

Which one should I use?

  • var or the explicit type? Use var when 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 like List<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

  1. Using var when the type becomes unclear. If a reader can't tell what the variable is without hunting, write the type. Also remember var list = new ArrayList<>() gives you ArrayList<Object>.
  2. Thinking var makes Java dynamic. The type is fixed at compile time. You can't assign a different type later, and you can't use var for fields, parameters or return types.
  3. 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.
  4. Using the wrong accessor name. A record's accessor is p.x(), not p.getX(). And equality is based on values, so two records with the same data are equals.
  5. Forgetting what a permitted subclass must say. Each one has to be final, sealed or non-sealed, and it must sit in the same module (or package). Otherwise, the code won't compile.
  6. Adding a default branch to a switch over a sealed type. It silences the compiler's exhaustiveness check. When you add a new subtype later, it falls into default without any warning. Leave default out so the compiler can protect you.

Quick recap

  • var lets 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.
  • var needs an initial value that shows the type. It can't be used for fields, parameters, return types, null or 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(), and equals, hashCode and toString. It's implicitly final, extends java.lang.Record and 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 be final, sealed or non-sealed.
  • Sealed types give you an exhaustive switch with no default, so the compiler tells you when you forget a case.
  • Records plus sealed interfaces plus pattern-matching switch is 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)