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 classfor value-like types (String,Integer, immutable types).finalmethods 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, andtoStringbased 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
recordfor immutable data with structural equality (DTOs, value objects, simple data carriers). - Use
classfor 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’thisis 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
Loggerthat 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.