Sealed Hierarchies & Objects
Closed type hierarchies and the object family — sealed classes and interfaces, exhaustive when, object declarations as singletons, companion objects instead of static members, and anonymous objects.
9 questions
JuniorTheoryVery commonWhat does a sealed class give you that an ordinary abstract class does not?
What does a sealed class give you that an ordinary abstract class does not?
A sealed class fixes its set of direct subclasses at compile time: they must sit in the same package and module. The compiler therefore knows every case, so a when over the type needs no else and is checked for completeness.
Common mistakes
- ✗Thinking
sealedonly means the class cannot be instantiated - ✗Declaring a subclass in another module and expecting it to compile
- ✗Believing a
whenover a sealed type still needs anelse
Follow-up questions
- →What happens to an existing
whenwhen you add a new subclass? - →Where exactly must the direct subclasses of a sealed class be declared?
JuniorTheoryVery commonWhat does an object declaration compile to, and why is it thread-safe?
What does an object declaration compile to, and why is it thread-safe?
It compiles to a class with a private constructor and a static INSTANCE field set in the static initialiser. The JVM runs that initialiser once, on first access, under class-loading guarantees, so the singleton is lazy and needs no lock.
Common mistakes
- ✗Treating an
objectdeclaration as a mere namespace for functions - ✗Adding manual locking to make a Kotlin
objectthread-safe - ✗Expecting the instance to be built at process start rather than on first use
Follow-up questions
- →When is an
objectdeclaration a poor fit for a dependency you want to swap in tests? - →How does the lazy delegate
by lazydiffer from anobjectdeclaration?
JuniorTheoryCommonHow does a companion object differ from Java static members?
How does a companion object differ from Java static members?
A companion object is not static: it is a real object instance in a static Companion field of its class, and it may hold state or implement an interface. Java callers write Foo.Companion.bar() unless @JvmStatic emits a real static member.
Common mistakes
- ✗Calling a companion member a static member of the class
- ✗Expecting Java to see
Foo.bar()without@JvmStatic - ✗Thinking one class may declare several companion objects
Follow-up questions
- →Why would you have a companion object implement an interface?
- →What does
@JvmStaticactually add to the generated bytecode?
MiddleDebuggingCommonA new sealed subtype silently takes the wrong when branch — find and fix it
A new sealed subtype silently takes the wrong when branch — find and fix it
The else branch destroys exhaustiveness: while it is there the compiler stops checking the when, so Pending quietly lands in else at runtime. Drop else and handle each subtype, and any future case then fails the build.
Common mistakes
- ✗Adding
elseto awhenover a sealed type just to silence the compiler - ✗Believing a new subtype cannot slip through because the code still compiles
- ✗Thinking
elseand a full set of branches are interchangeable
Follow-up questions
- →Where else in a codebase would this bug hide after a new subtype is added?
- →When is an
elsebranch over a sealed type actually justified?
MiddleDesignCommonYou are modelling the outcome of a payment request inside a library that other teams in your company depend on. Today an outcome is one of three cases — approved, declined, pending — and every caller branches over all of them to render a message. Two more cases, a chargeback and a manual review, are already planned for next quarter. You do not control the calling code, and the one thing that must never happen is a caller silently mis-handling a case that was added after it was written. One colleague proposes a plain abstract class PaymentOutcome with three subclasses; another proposes a sealed class. Which do you choose, what does that choice buy you on the day the fourth case ships, and in which situation would the abstract class in fact be the right call?
You are modelling the outcome of a payment request inside a library that other teams in your company depend on. Today an outcome is one of three cases — approved, declined, pending — and every caller branches over all of them to render a message. Two more cases, a chargeback and a manual review, are already planned for next quarter. You do not control the calling code, and the one thing that must never happen is a caller silently mis-handling a case that was added after it was written. One colleague proposes a plain abstract class PaymentOutcome with three subclasses; another proposes a sealed class. Which do you choose, what does that choice buy you on the day the fourth case ships, and in which situation would the abstract class in fact be the right call?
Choose sealed: the case set is closed, so callers get an exhaustive when and a new case breaks their build instead of being silently mis-rendered. An abstract class is right only when outside code must add subtypes you cannot foresee.
Common mistakes
- ✗Assuming an abstract class can also give callers an exhaustive
when - ✗Thinking sealed forbids the library author from adding new cases
- ✗Keeping an
elsebranch in caller code and calling the design safe
Follow-up questions
- →How would a caller in another module add its own case if you chose the abstract class?
- →What migration do callers face on the day a fourth sealed case ships?
MiddleTheoryOccasionalHow does an anonymous object : Listener { ... } differ from an object declaration?
How does an anonymous object : Listener { ... } differ from an object declaration?
An anonymous object is an expression: it yields a fresh instance each time it is evaluated, whereas an object declaration is one named singleton. It can implement several interfaces at once, and it may capture and even mutate local variables.
Common mistakes
- ✗Assuming the expression hands back the same instance on every evaluation
- ✗Thinking an anonymous object can implement only one interface
- ✗Applying the Java effectively-final rule to captured locals in Kotlin
Follow-up questions
- →How many objects are created if such an expression sits inside a loop?
- →What is the declared type of a function that returns an anonymous object?
MiddleTheoryOccasionalWhen would you pick a sealed interface over a sealed class?
When would you pick a sealed interface over a sealed class?
Pick the interface when a subtype must also extend another class, or belong to two closed hierarchies at once: single inheritance makes a sealed class forbid both. A sealed class wins when the cases share state or a constructor.
Common mistakes
- ✗Thinking a sealed interface leaves the hierarchy open to any implementer
- ✗Assuming a subtype can extend a sealed class and another class at once
- ✗Expecting a sealed interface to carry constructor state
Follow-up questions
- →Can one class belong to two different sealed interfaces at the same time?
- →Where must the implementations of a sealed interface be declared?
SeniorTheoryOccasionalHow does the Kotlin sealed modifier differ from Java 17 sealed with permits?
How does the Kotlin sealed modifier differ from Java 17 sealed with permits?
Kotlin infers the permitted set from the subtypes in the same package and module, so it has no permits clause and no non-sealed hatch. Java 17 needs permits unless the subtypes share the file, and each must be final, sealed or non-sealed.
Common mistakes
- ✗Looking for a
permitsclause in Kotlin - ✗Expecting a
non-sealedsubtype to reopen a Kotlin hierarchy - ✗Assuming a Java subtype may stay unmarked under a sealed parent
Follow-up questions
- →Why does Java 17 need
permitsonce the subtypes live in another file? - →What does
non-sealedallow that Kotlin deliberately has no equivalent for?
SeniorTheoryOccasionalWhy is the hand-rolled lazy-init idiom double-checked locking pointless in Kotlin?
Why is the hand-rolled lazy-init idiom double-checked locking pointless in Kotlin?
An object declaration is already lazy and thread-safe: the JVM assigns INSTANCE in a static initialiser that it runs exactly once, under class-loading guarantees. Double-checked locking rebuilds that by hand with a @Volatile field and a lock.
Common mistakes
- ✗Thinking a Kotlin
objectneeds a@Volatilefield to be safe - ✗Believing the instance is built eagerly when the process starts
- ✗Assuming every access to an
objecttakes a lock
Follow-up questions
- →What does the JVM guarantee about running the static initialiser of a class?
- →When would the lazy delegate
by lazybe a better tool than anobjectdeclaration?