Skip to content

09 · Enums, Records & Sealed Classes

This module covers three modern Java features that make data modeling more concise and safer: enum for fixed sets of constants, record for immutable data carriers, and sealed classes for restricted class hierarchies.

Enums with fields, constructors, and methods

An enum is more than a list of names — each constant can carry its own data and every constant shares the enum's methods.

public enum Planet {
    MERCURY(3.303e+23, 2.4397e6),
    VENUS(4.869e+24, 6.0518e6),
    EARTH(5.976e+24, 6.37814e6);

    private final double mass;    // kilograms
    private final double radius;  // meters

    // enum constructors are implicitly private -- called once per constant
    Planet(double mass, double radius) {
        this.mass = mass;
        this.radius = radius;
    }

    double surfaceGravity() {
        final double G = 6.67300E-11;
        return G * mass / (radius * radius);
    }
}

for (Planet p : Planet.values()) {
    System.out.printf("%-8s gravity: %.2f%n", p, p.surfaceGravity());
}
// Output:
// MERCURY  gravity: 3.70
// VENUS    gravity: 8.87
// EARTH    gravity: 9.80

values() returns every constant in declaration order, and every enum automatically gets name(), ordinal() (its position, starting at 0), and a valueOf(String) static lookup.

Enums implementing an interface

Enum constants can override methods per-constant, which is a clean alternative to a chain of if/switch statements — each constant supplies its own behavior.

public interface Operation {
    int apply(int a, int b);
}

public enum BasicOperation implements Operation {
    ADD {
        @Override public int apply(int a, int b) { return a + b; }
    },
    SUBTRACT {
        @Override public int apply(int a, int b) { return a - b; }
    },
    MULTIPLY {
        @Override public int apply(int a, int b) { return a * b; }
    };
}

for (BasicOperation op : BasicOperation.values()) {
    System.out.println(op + "(3, 4) = " + op.apply(3, 4));
}
// ADD(3, 4) = 7
// SUBTRACT(3, 4) = -1
// MULTIPLY(3, 4) = 12

Records — concise immutable data carriers

A record declares its fields once in the header, and the compiler generates a canonical constructor, private final fields, public accessor methods (named after the field, not getX()), plus equals, hashCode, and toString — all automatically.

public record Point(int x, int y) {}

Point p1 = new Point(3, 4);
Point p2 = new Point(3, 4);

System.out.println(p1.x());          // 3 -- accessor, not getX()
System.out.println(p1);               // Point[x=3, y=4] -- generated toString
System.out.println(p1.equals(p2));    // true -- generated equals compares all fields
System.out.println(p1.hashCode() == p2.hashCode());   // true

Compare this to writing the equivalent class by hand with a constructor, three accessors, equals, hashCode, and toString — records eliminate that boilerplate entirely for simple data holders.

Records with validation in a compact constructor

A compact constructor (no parameter list — it reuses the record's declared components) lets you validate or normalize input before the fields are assigned.

public record Range(int min, int max) {
    // compact constructor -- runs before the fields are set
    public Range {
        if (min > max) {
            throw new IllegalArgumentException("min (" + min + ") cannot exceed max (" + max + ")");
        }
    }

    public int length() {
        return max - min;   // records can have extra methods too
    }
}

Range valid = new Range(1, 10);
System.out.println(valid.length());   // 9

Range invalid = new Range(10, 1);   // throws IllegalArgumentException

Sealed classes and interfaces

A sealed type declares exactly which classes are allowed to extend or implement it via permits — closing off the hierarchy so the compiler (and readers) know the complete set of subtypes.

public sealed interface Shape permits Circle, Rectangle, Triangle {}

public record Circle(double radius) implements Shape {}
public record Rectangle(double width, double height) implements Shape {}
public record Triangle(double base, double height) implements Shape {}

Permitted subtypes must be final, sealed, or non-sealed themselves — records are implicitly final, so they qualify automatically.

Exhaustive switch pattern matching over a sealed hierarchy

Because the compiler knows every possible subtype of a sealed interface, a switch expression over one can skip a default branch entirely once every case is covered — and the compiler will error if a new subtype is added later without updating the switch.

public static double area(Shape shape) {
    return switch (shape) {
        case Circle c -> Math.PI * c.radius() * c.radius();
        case Rectangle r -> r.width() * r.height();
        case Triangle t -> 0.5 * t.base() * t.height();
        // no default needed -- Circle, Rectangle, Triangle are the only permitted types
    };
}

List<Shape> shapes = List.of(new Circle(2), new Rectangle(3, 4), new Triangle(6, 2));
for (Shape s : shapes) {
    System.out.printf("%.2f%n", area(s));
}
// Output:
// 12.57
// 12.00
// 6.00

This combines pattern matching (from Module 1's instanceof form) with switch, and is the modern, type-safe replacement for long instanceof/cast chains.

Cheat sheet

Feature Purpose Key trait
enum Fixed set of named constants Can hold fields, methods, per-constant bodies
record Immutable data carrier Auto-generates constructor, accessors, equals/hashCode/toString
sealed Restrict which types may extend/implement Enables exhaustive switch with no default

How It Actually Works

An enum compiles to a real final class extending java.lang.Enum, with each constant becoming a public static final field holding a singleton instance, all created once in a synthesized static initializer and stored in a hidden values() array. switch on an enum doesn't compare by name at runtime — the compiler generates a synthetic lookup table mapping each constant's ordinal() to a case index, which is why reordering enum constants can silently change switch behavior if code ever depended on ordinal values.

Records are compiler-generated data carriers: declaring record Point(int x, int y) makes javac emit a final class with private final fields, a canonical constructor, accessor methods named exactly x()/y() (not getX()), plus equals(), hashCode(), and toString() implementations generated from the component list via an invokedynamic-based bootstrap (ObjectMethods) — the JVM builds these method bodies once, reflectively, from the record's component descriptors, rather than the compiler inlining boilerplate text.

sealed classes/interfaces attach a PermittedSubclasses attribute to the class file, checked by the verifier at class-loading time — it's not just a compiler suggestion; a rogue subclass added later that isn't in the permits list fails to link at runtime with an IncompatibleClassChangeError even if it compiles against an old version of the sealed type.

Exercise

Define a sealed interface PaymentMethod permits CreditCard, BankTransfer, Cash, where CreditCard and BankTransfer are records (with fields like account/card number) and Cash is a record with no fields. Write a method processingFee(PaymentMethod method) using an exhaustive switch expression that returns a flat 2% fee for CreditCard, a flat $1.50 for BankTransfer, and 0 for Cash. Then define an enum Currency with constants USD, EUR, GBP, each carrying a String symbol field set via an enum constructor, and a method format(double amount) that returns the symbol concatenated with the amount.