Seal Your Hierarchies
interface Cargo {}
record Food(int rations) implements Cargo {}
record Fuel(double liters) implements Cargo {}
record Oxygen(int tanks) implements Cargo {}
class LoadPlanner {
double weightInKg(Cargo cargo) {
if (cargo instanceof Food food) {
return food.rations() * 0.8;
} else if (cargo instanceof Fuel fuel) {
return fuel.liters() * 0.82;
} else if (cargo instanceof Oxygen oxygen) {
return oxygen.tanks() * 15.0;
}
throw new IllegalArgumentException("Unknown cargo " + cargo);
}
}
Cargo is an open interface, so anyone can implement it, anywhere in the code base. The compiler can't know which kinds of cargo exist. That's why weightInKg() has to end with a throw for a case that nobody expects.
When someone adds a new kind of cargo, say Water, everything still compiles. The missing branch only shows up as an exception at runtime, possibly in production, and only if a test happens to load water.
sealed interface Cargo permits Food, Fuel, Oxygen {}
record Food(int rations) implements Cargo {}
record Fuel(double liters) implements Cargo {}
record Oxygen(int tanks) implements Cargo {}
class LoadPlanner {
double weightInKg(Cargo cargo) {
return switch (cargo) {
case Food food -> food.rations() * 0.8;
case Fuel fuel -> fuel.liters() * 0.82;
case Oxygen oxygen -> oxygen.tanks() * 15.0;
};
}
}
Since Java 17, a sealed interface lists all its implementations after permits. Now the compiler knows every kind of cargo, so a switch with type patterns, available since Java 21, can cover them all without a default branch. The throw is gone.
The real benefit shows when the hierarchy grows: adding Water to the permits clause makes this switch fail to compile until it handles water. The compiler finds every place you need to change.
Records are implicitly final, which is what a sealed hierarchy requires. Other permitted classes must be declared final, sealed, or non-sealed. Seal hierarchies that you own and switch over; keep interfaces open where others should plug in their own implementations.