Polyglot
Languages Java oop
Java § oop

Classes, records, and sealed types

Java is fully object-oriented: classes are first-class user-defined reference types, with constructors, methods, fields, and the full surface of inheritance. The language admits single inheritance of classes plus multiple implementation of interfaces; polymorphism is realised through virtual dispatch on instance methods. Beyond the classical class, Java provides records (Java 14) — concise immutable data classes — and sealed types (Java 17) — closed type hierarchies that admit exhaustive pattern matching. Together with abstract classes and the conventional design patterns, the constructs cover the full object-modelling axes.

This page covers classes, records, sealed types, and the conventions for using each. Interfaces are treated separately in Interfaces because Java interfaces are first-class language constructs distinct from classes.

Classes

A class is a user-defined reference type:

public class Counter {
    private int count = 0;

    public Counter() { }
    public Counter(int initial) { this.count = initial; }

    public void increment() { count++; }
    public int  value()     { return count; }
}

Counter c = new Counter(10);
c.increment();
int v = c.value();          // 11

Members may be:

  • Fields — variables that each instance carries.
  • Methods — instance-callable operations.
  • Constructors — special methods that initialise instances.
  • Static members — fields and methods associated with the type, not with instances.
  • Nested types — class, interface, enum, or record declarations inside another type.
  • Initialiser blocks — code that runs at instance or class initialisation.

Constructors

A constructor initialises an instance:

public class Document {
    private final String title;
    private final String body;

    public Document(String title, String body) {
        this.title = title;
        this.body  = body;
    }

    public Document(String title) {
        this(title, "");           // delegate to the other constructor
    }
}

The this(...) call (in the first statement) chains to another constructor of the same class. The super(...) call (also first-statement only) chains to a base-class constructor:

public class Special extends Base {
    public Special(int n) {
        super(n * 2);              // explicit base call
        // ...
    }
}

If neither this(...) nor super(...) appears, the compiler implicitly inserts super() — a call to the no-arg base constructor. If the base class has no no-arg constructor, the derived class must explicitly call super(...) with arguments.

Inheritance

A class extends at most one other class:

public class Animal {
    protected final String name;

    public Animal(String name) { this.name = name; }

    public String name() { return name; }
    public void   speak() { System.out.println("..."); }
}

public class Dog extends Animal {
    private final String breed;

    public Dog(String name, String breed) {
        super(name);
        this.breed = breed;
    }

    @Override
    public void speak() { System.out.println("woof"); }

    public String breed() { return breed; }
}

Java admits multiple-interface implementation but single inheritance of classes. The single-inheritance restriction simplifies the model; interface-based polymorphism (covered in Interfaces) handles the cases that languages with multiple inheritance use it for.

The conventional inheritance form is class Derived extends Base; multiple-interface implementation uses class C extends Base implements I1, I2, ....

Method dispatch and @Override

Java methods are virtual by default — calls through a base-class reference dispatch to the derived-class implementation:

Animal a = new Dog("Rex", "Lab");
a.speak();                  // "woof" — the Dog.speak() implementation runs

The mechanism is implemented through a vtable per class; each instance carries a hidden pointer to its class’s vtable, and method dispatch indexes into the table.

The @Override annotation verifies that the method overrides a superclass or interface method:

public class Dog extends Animal {
    @Override
    public void speak() { /* ... */ }

    @Override
    public void run() { /* ... */ }    // ERROR if Animal has no `run`
}

The annotation is recommended whenever a method overrides another; it catches typos and signature mismatches at compile time.

final classes and methods

A final class cannot be subclassed; a final method cannot be overridden:

public final class String { /* ... */ }       // cannot be extended
public class Base {
    public final void utility() { /* ... */ }  // cannot be overridden
}

The conventional uses:

  • final class for value-like types (String, Integer, immutable types).
  • final methods for safety-critical methods that subclasses should not override.

Abstract classes and methods

A class declared abstract cannot be instantiated; methods declared abstract have no body and must be implemented by subclasses:

public abstract class Shape {
    public abstract double area();      // no body; must be overridden
    public abstract double perimeter();

    public final double areaToPerimeterRatio() {
        return area() / perimeter();
    }
}

public class Circle extends Shape {
    private final double radius;

    public Circle(double radius) { this.radius = radius; }

    @Override
    public double area() { return Math.PI * radius * radius; }

    @Override
    public double perimeter() { return 2 * Math.PI * radius; }
}

Abstract classes are the conventional alternative to interfaces when the type needs:

  • Instance fields (interfaces cannot have instance fields).
  • A constructor (interfaces cannot have constructors).
  • Significant shared implementation that derived classes inherit.

For pure-interface needs (just method signatures), prefer interface over abstract class.

Records (Java 14)

A record is a concise class for immutable data with structural equality:

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

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

boolean eq = p1.equals(p2);             // true: structural equality
double  x  = p1.x();                     // accessor (no x() field syntax)
String  s  = p1.toString();              // "Point[x=3.0, y=4.0]"

The compiler synthesises:

  • A primary constructor taking the components in declaration order.
  • Accessor methods named after each component (not getX).
  • equals, hashCode, and toString based on the components.
  • Private final fields for each component.

Records are reference types — they live on the heap — but exhibit value-like behaviour: they are immutable and support structural equality.

Custom members

Records admit additional members:

public record Person(String firstName, String lastName, int age) {
    public String fullName() {
        return firstName + " " + lastName;
    }

    public boolean isAdult() {
        return age >= 18;
    }
}

The structural equality is based only on the components, not on these additions. Static factory methods are conventional for alternative construction:

public record Color(int r, int g, int b) {
    public static Color rgb(int r, int g, int b) { return new Color(r, g, b); }
    public static Color black() { return new Color(0, 0, 0); }
    public static Color white() { return new Color(255, 255, 255); }
}

Compact constructors

Records admit a compact constructor for validation and normalisation:

public record Email(String address) {
    public Email {
        if (address == null || !address.contains("@")) {
            throw new IllegalArgumentException("invalid email");
        }
        address = address.toLowerCase();          // normalisation
    }
}

The compact form has no parameter list; it operates on the implicit parameters and runs before the field assignment. Modifications to the parameters affect the stored values.

Records vs classes

The conventional choice:

  • Use record for immutable data with structural equality (DTOs, value objects, simple data carriers).
  • Use class for everything else (services, mutable types, entities with identity).

Records cannot extend other classes (they implicitly extend Record), but they may implement interfaces:

public record Position(int x, int y) implements Comparable<Position> {
    @Override
    public int compareTo(Position other) {
        int dx = Integer.compare(x, other.x);
        return dx != 0 ? dx : Integer.compare(y, other.y);
    }
}

Sealed types (Java 17)

A sealed class or interface restricts which classes may extend or implement it:

public sealed class Shape permits Circle, Square, Triangle { }

public final class Circle   extends Shape { /* ... */ }
public final class Square   extends Shape { /* ... */ }
public final class Triangle extends Shape { /* ... */ }

The permits clause names the permitted subclasses; any other class extending Shape produces a compile-time error.

Each permitted subclass must be declared final, sealed (with its own permits), or non-sealed (which lifts the restriction):

public sealed class Shape permits Circle, Square, Composite { }

public final class Circle extends Shape { /* ... */ }
public final class Square extends Shape { /* ... */ }

public non-sealed class Composite extends Shape { /* ... */ }
public class CompositeOfTwo extends Composite { /* ... */ }    // OK: Composite is non-sealed

The non-sealed modifier admits arbitrary subclasses through that branch.

The principal use is closed type hierarchies — sum types where the set of variants is fixed at design time. Combined with switch expressions, sealed types admit exhaustive pattern matching (the compiler verifies every subtype is handled):

public sealed interface JsonValue
    permits JsonNull, JsonBool, JsonNumber, JsonString, JsonArray, JsonObject { }

public record JsonNull()                          implements JsonValue { }
public record JsonBool(boolean value)              implements JsonValue { }
public record JsonNumber(double value)             implements JsonValue { }
public record JsonString(String value)             implements JsonValue { }
public record JsonArray(List<JsonValue> items)     implements JsonValue { }
public record JsonObject(Map<String, JsonValue> entries) implements JsonValue { }

The full treatment of pattern matching against sealed types is in Pattern matching.

Static members

A static member (field or method) belongs to the type, not to instances:

public class Counter {
    public static int instanceCount = 0;

    public Counter() {
        instanceCount++;
    }
}

Counter.instanceCount;       // accessed by class name
new Counter();
new Counter();
System.out.println(Counter.instanceCount);    // 2

Static fields are shared across all instances; static methods do not have access to instance fields or this.

A static initialiser block runs once when the class is loaded:

public class Config {
    public static final Map<String, String> DEFAULTS;

    static {
        DEFAULTS = new HashMap<>();
        DEFAULTS.put("host", "localhost");
        DEFAULTS.put("port", "8080");
    }
}

The conventional use is initialising complex static state.

Nested types

Java admits four kinds of nested types:

Static nested classes

Declared with static:

public class Outer {
    public static class Nested {
        // no implicit reference to Outer
    }
}

Outer.Nested n = new Outer.Nested();

Static nested classes are essentially top-level classes that happen to live in another class’s namespace.

Inner classes

Non-static nested classes are inner classes; they hold an implicit reference to the enclosing instance:

public class Outer {
    private int outerField;

    public class Inner {
        public int read() { return outerField; }    // accesses Outer.this.outerField
    }
}

Outer outer = new Outer();
Outer.Inner inner = outer.new Inner();

The implicit reference admits access to the outer instance’s members but produces lifetime entanglement: an inner class instance keeps the outer instance alive for as long as it lives. The conventional discipline is to prefer static nested classes unless the implicit-this is genuinely useful.

Local classes

Declared inside a method:

public void process() {
    class Helper {
        public void doWork() { /* ... */ }
    }

    Helper h = new Helper();
    h.doWork();
}

Local classes are scoped to the enclosing method and may access effectively final local variables.

Anonymous classes

A one-off class expression:

Runnable task = new Runnable() {
    @Override
    public void run() {
        System.out.println("running");
    }
};

Anonymous classes are largely superseded by lambdas (Java 8+) for single-method interfaces. They remain useful when:

  • The target interface has more than one method.
  • The implementation needs instance fields.
  • The class needs to access its own this (lambdas’ this is the enclosing instance).

Object methods to override

Every class implicitly inherits from Object. Several methods are conventionally overridden:

equals(Object other)

@Override
public boolean equals(Object other) {
    if (this == other) return true;
    if (!(other instanceof Person p)) return false;
    return Objects.equals(firstName, p.firstName)
        && Objects.equals(lastName, p.lastName)
        && age == p.age;
}

The contract: reflexive, symmetric, transitive, consistent, and null produces false.

hashCode()

@Override
public int hashCode() {
    return Objects.hash(firstName, lastName, age);
}

The contract: equal objects have equal hash codes. The Objects.hash helper handles the multi-field combination.

toString()

@Override
public String toString() {
    return String.format("Person[firstName=%s, lastName=%s, age=%d]",
                          firstName, lastName, age);
}

The contract: a textual representation suitable for logging and debugging.

For records, equals, hashCode, and toString are auto-generated; user code does not override them.

A note on inheritance versus composition

The conventional advice: prefer composition over inheritance. Java admits inheritance freely, but the brittleness of deep hierarchies makes it a tool of last resort. Composition (a class containing other classes as fields) is the more flexible default.

When inheritance is appropriate:

  • Genuine is-a relationships (Square is-a Shape).
  • Open-set polymorphism where the framework provides a base class.
  • Pre-existing class hierarchies that the program must integrate with.

When composition is appropriate:

  • Code reuse without is-a (a Logger that several classes use).
  • Configurable behaviour (strategy pattern, dependency injection).
  • Avoiding the coupling that deep inheritance introduces.

The patterns interact: the strategy pattern uses composition to inject behaviour; the decorator pattern uses composition to extend behaviour; the visitor pattern uses inheritance to dispatch operations. Modern Java prefers composition for application code and reserves inheritance for framework integration.