LLD Dojo

Java, from nothing · chapter 10 of 33

enum and record: the two types you will reach for most

Chapter 1.10 · Part 1, Java from nothing · about 30 minutes

What you need before this chapter: chapters 1.1 through 1.9. You should be comfortable with classes, objects, fields, methods, constructors, private, references versus values, == versus equals, and null. You should also know how inheritance and polymorphism work, and how interfaces and abstract classes differ. Finally, you should be able to use List/Map/Set and generics, and know how to throw and catch an exception.

When you finish this chapter you will be able to:


1. The idea in plain words

A parking lot has a small, fixed set of vehicle types: car, motorcycle, truck, and nothing else, ever. Before Java had a type built for exactly this, the common way to write it down was a group of constants: public static final int CAR = 0, public static final int MOTORCYCLE = 1, and so on. This compiles, but CAR is just an int. A method that expects a vehicle type happily accepts 7, or the constant for a completely unrelated idea that also happens to be 0. Nothing checks that the number means what you think it means.

An enum fixes this. It is a type with a fixed, named list of values, known completely at compile time. It can carry its own fields and methods just like a class, because under the hood it is one. CAR is no longer a number that might collide with some other 0. It is a value of type VehicleType, and nothing else can be assigned where a VehicleType is expected.

Chapter 1.3 has a matching problem, of a different shape. Writing equals, hashCode, and a readable toString by hand, for every small class that just holds a few values, is tedious. It is easy to get wrong, and every one has to be redone whenever a field is added. Think back to Ticket: two fields, and three methods to write correctly around them.

A record writes those three methods for you. Declare the fields once, and the compiler generates a constructor, one accessor per field, equals, hashCode, and toString, all in agreement with each other, none of it typed by hand. A record is built for exactly this case: a small, unchanging holder of values, where two objects with the same values should count as equal.


2. Type this

First, VehicleType.java:

public enum VehicleType {
    CAR(4),
    MOTORCYCLE(2),
    TRUCK(6);

    private final int wheelCount;

    VehicleType(int wheelCount) {
        this.wheelCount = wheelCount;
    }

    public int getWheelCount() {
        return wheelCount;
    }
}

Then VehicleTypeDemo.java:

public class VehicleTypeDemo {
    static double feePerHour(VehicleType type) {
        return switch (type) {
            case CAR -> 30.0;
            case MOTORCYCLE -> 10.0;
            case TRUCK -> 50.0;
        };
    }

    public static void main(String[] args) {
        for (VehicleType type : VehicleType.values()) {
            System.out.println(type + " has " + type.getWheelCount() + " wheels, fee " + feePerHour(type));
        }
    }
}

Now TicketRecord.java, a record version of the Ticket class from earlier chapters:

public record TicketRecord(String plate, int arrivedAtMinute) {
}

That is the entire declaration. And TicketRecordDemo.java:

public class TicketRecordDemo {
    public static void main(String[] args) {
        TicketRecord a = new TicketRecord("KA-01-4432", 555);
        TicketRecord b = new TicketRecord("KA-01-4432", 555);

        System.out.println("a: " + a);
        System.out.println("a.equals(b): " + a.equals(b));
        System.out.println("a.hashCode() == b.hashCode(): " + (a.hashCode() == b.hashCode()));
        System.out.println("a == b: " + (a == b));
        System.out.println("plate: " + a.plate());
    }
}

3. Run it

javac VehicleType.java VehicleTypeDemo.java
java VehicleTypeDemo
CAR has 4 wheels, fee 30.0
MOTORCYCLE has 2 wheels, fee 10.0
TRUCK has 6 wheels, fee 50.0
javac TicketRecord.java TicketRecordDemo.java
java TicketRecordDemo
a: TicketRecord[plate=KA-01-4432, arrivedAtMinute=555]
a.equals(b): true
a.hashCode() == b.hashCode(): true
a == b: false
plate: KA-01-4432

4. What just happened, line by line

CAR(4), MOTORCYCLE(2), TRUCK(6); declares the three values VehicleType can ever have, each one calling the constructor immediately after it with the argument in parentheses. The semicolon after TRUCK(6) is required the moment an enum has anything else in its body, a field or a method, after the constant list. Every one of these three values is created exactly once, the first time VehicleType is used, and reused forever after; you never write new VehicleType(...) yourself, and chapter 5 shows what happens if you try.

private final int wheelCount; and the constructor next to it work exactly like a constructor and field in any other class, because VehicleType genuinely is a class, one the compiler generates most of for you. VehicleType(int wheelCount) is implicitly private, since nothing outside the enum's own constant list is ever allowed to build a new one.

return switch (type) { case CAR -> 30.0; ... }; is a switch expression, a newer form that produces a value rather than just branching. Because type is a VehicleType, and every branch here covers one of its three possible values with no default, the compiler can check that every possible input is handled. Add a fourth constant to VehicleType later without updating this switch, and it stops compiling rather than silently falling through, which chapter 5 demonstrates directly.

public record TicketRecord(String plate, int arrivedAtMinute) {} declares two things at once: the record's components, in parentheses, and an empty body, because there is nothing left to add. From that one line, the compiler generates a constructor that takes both values in order. It also generates the accessors plate() and arrivedAtMinute(), not getPlate(); a record names its accessor after the component itself. It generates equals, hashCode, and toString too, all based on the two fields.

a.equals(b) returning true for two separately constructed objects is the payoff. Chapter 1.3 would have had you write exactly this comparison by hand, field by field, and get it wrong if you compared only plate and forgot arrivedAtMinute, or vice versa. The record's generated equals compares every component, always, and the generated hashCode is built to agree with it: equal objects always produce equal hash codes, which chapter 1.7's HashMap section depends on completely. a == b is false, exactly as it would be for any two objects built with separate calls to new; a record changes what equals means, never what == means.


5. Errors you are likely to hit

Forgetting the semicolon after an enum's constant list. Add a field to VehicleType without the semicolon:

public enum MissingSemicolon {
    CAR(4),
    MOTORCYCLE(2)

    private final int wheelCount;

    MissingSemicolon(int wheelCount) {
        this.wheelCount = wheelCount;
    }
}
MissingSemicolon.java:3: error: ',', '}', or ';' expected
    MOTORCYCLE(2)
                 ^

The parser cannot tell where the constant list ends and the rest of the class body begins without that semicolon. It is easy to forget, because an enum with no fields or methods never needs one.

Trying to build an enum value yourself. VehicleType's constructor being implicitly private is not a suggestion:

VehicleType t = new VehicleType(4);
NewEnum.java:3: error: enum classes may not be instantiated
        VehicleType t = new VehicleType(4);
                        ^

The three values, CAR, MOTORCYCLE, TRUCK, are the only VehicleType objects that will ever exist for the life of the program. This guarantee, not just a convention, is what section 6 puts to use.

A switch expression that does not cover every value. Add BUS(6) to the enum but leave the switch expression with only three cases, no default:

ExhaustiveCheck.java:3: error: the switch expression does not cover all possible input values
        return switch (type) {
               ^

This is the compiler catching a real bug. A plain if-chain on integer constants could never catch it: a new category added to the enum, forgotten in one of the several switch statements that branch on it elsewhere in the codebase.

Reaching for a record's field directly. A record's components are private and final, reachable only through the generated accessor:

TicketRecord t = new TicketRecord("KA-01-4432", 555);
t.plate = "MH-12-9001";
RecordFieldAccess.java:4: error: plate has private access in TicketRecord
        t.plate = "MH-12-9001";
         ^

Use t.plate() to read it. There is no way to write it at all, because a record has no generated setters. Changing a TicketRecord means building a new one.


6. Your turn

Add a compact constructor to TicketRecord that rejects a negative arrivedAtMinute, the same validation chapter 1.9 put in the ordinary Ticket class's constructor. A compact constructor for a record omits the parameter list and the field assignments; it only contains the checks, and the compiler still generates the assignments for you afterward.

Do it before reading on.

The answer:

public record TicketRecordValidated(String plate, int arrivedAtMinute) {
    public TicketRecordValidated {
        if (arrivedAtMinute < 0) {
            throw new IllegalArgumentException("arrivedAtMinute cannot be negative: " + arrivedAtMinute);
        }
    }
}
TicketRecordValidated ok = new TicketRecordValidated("KA-01-4432", 555);
System.out.println(ok);
TicketRecordValidated bad = new TicketRecordValidated("KA-01-4432", -5);
System.out.println(bad);
TicketRecordValidated[plate=KA-01-4432, arrivedAtMinute=555]
Exception in thread "main" java.lang.IllegalArgumentException: arrivedAtMinute cannot be negative: -5
	at TicketRecordValidated.<init>(TicketRecordValidated.java:4)
	at ValidatedDemo.main(ValidatedDemo.java:5)

The first ticket prints normally. The second never gets built at all: chapter 1.9's rule applies here without changes, because a compact constructor is still a constructor, and throw still stops execution before the object exists.


Going deeper

An enum is a real class with a private constructor, and that is what makes it the correct way to write a singleton. A singleton is a type the rest of the program can only ever have one instance of. Section 5 already showed why: new VehicleType(...) does not compile, anywhere, outside the enum's own constant list. Put that same guarantee to work on purpose:

public enum Gate {
    INSTANCE;

    private int carsPassed = 0;

    public void recordEntry() {
        carsPassed++;
    }

    public int getCarsPassed() {
        return carsPassed;
    }
}
Gate.INSTANCE.recordEntry();
Gate.INSTANCE.recordEntry();

Gate first = Gate.INSTANCE;
Gate second = Gate.INSTANCE;
System.out.println("same instance: " + (first == second));
System.out.println("cars passed: " + Gate.INSTANCE.getCarsPassed());
same instance: true
cars passed: 2

Gate.INSTANCE is the same object everywhere it appears. The JVM's own class loading guarantees that, instead of a hand-written check for "has one already been created?" that older singleton code has to get right itself. Chapter 3 covers the classic hand-written singleton and the ways it goes wrong under multiple threads. An enum singleton sidesteps that whole class of bug, which is why most Java engineers reach for it once they know it exists.

A record is only shallowly immutable, and this is the single most common mistake made with them. "Immutable" means the record's own reference to a component cannot change after construction. It says nothing about whether the object that reference points to can change:

public record LotSnapshot(String lotName, List<String> occupiedPlates) {
}
List<String> plates = new ArrayList<>();
plates.add("KA-01-4432");

LotSnapshot snapshot = new LotSnapshot("Airport Lot", plates);
System.out.println("before: " + snapshot.occupiedPlates());

plates.add("MH-12-9001");
System.out.println("after mutating the original list: " + snapshot.occupiedPlates());

snapshot.occupiedPlates().add("TN-22-7788");
System.out.println("after mutating through the record itself: " + snapshot.occupiedPlates());
before: [KA-01-4432]
after mutating the original list: [KA-01-4432, MH-12-9001]
after mutating through the record itself: [KA-01-4432, MH-12-9001, TN-22-7788]

LotSnapshot never reassigns occupiedPlates to point at a different list, so the record itself keeps its promise. But occupiedPlates() hands back the same mutable ArrayList it was given. Nothing stops the caller who built the record from continuing to add to it, and nothing stops any other code holding that same reference from doing the same. A genuinely immutable snapshot needs to copy the list in a compact constructor, typically with List.copyOf(occupiedPlates), which produces a list that throws on any attempted change. Without that copy, "immutable" describes only the record's own two component slots, never the list object one of them points at.


7. Why this matters in an interview

A parking lot's vehicle types, a payment method's kind, a ticket's state are all small, fixed sets. Reaching for enum instead of string constants or magic numbers is one of the fastest signals in a design round that you know the tool for the job. The exhaustive switch in section 5 is not a curiosity either. It is the compiler catching the exact bug that happens in real systems when someone adds a new category and one branch, somewhere, gets forgotten.

Records answer a question interviewers ask directly: "how would you represent an immutable value here, and what would equals need to do?" Chapter 1.3 taught you to answer that by hand. This chapter lets you answer it in one line. Say the shallow-immutability trap out loud the moment you reach for a record with a collection field in it. Noticing it yourself is worth more than getting caught by it later.


Next: chapter 2.1, Encapsulation: who is allowed to change this?, the first chapter of Part 2. Part 1 gave you the language. Part 2 is where the design habits interviewers actually grade begin.

← 1.9 Exceptions, and choosing what to throw · All chapters · 2.1 Encapsulation: deciding who can change your data →