Sealed Hierarchies & Objects
Both halves of this topic are about the same thing — knowledge that an ordinary class never gives the compiler. sealed fixes the set of subtypes: direct subclasses must live in the same package and the same compilation module, so the full list of cases is known at compile time. That is where the one real benefit of sealed comes from — the exhaustive when: branches are checked for completeness, and adding a new subtype turns from a silent runtime bug into a build error. object, in turn, fixes the number of instances: the declaration compiles to a class with a private constructor and a static INSTANCE field assigned in the static initialiser — and the JVM runs that initialiser exactly once, lazily, under the class-initialisation lock.
Every trap in the topic grows out of misreading those two mechanisms. An else in a when over a sealed type is a promise to the compiler that all remaining cases are handled the same way; while it is there, there is no completeness check at all, and a new subtype quietly inherits somebody else's behaviour. abstract also cannot be instantiated — but its subclass set is open, and it gives you no exhaustiveness whatsoever. A companion object is not a static: it is a real object in a static Companion field, which is why Java sees Foo.Companion.bar() — and why a companion can implement interfaces. And an anonymous object : Listener { … } is an expression: every evaluation creates a fresh instance and captures the enclosing scope, outer this included. The layer-by-layer breakdown is below.
Topic map
- Sealed hierarchies — the closed subtype set, where subclasses are allowed to live, how a sealed interface differs from a sealed class, and why this is neither an enum nor an abstract class.
- Exhaustive when — when the compiler must check the branches for completeness, why
elseswitches that check off, and how smart casts work inside a branch. - The object declaration as a singleton — the private constructor, the static
INSTANCEfield, and the class-initialisation guarantees that make double-checked locking pointless in Kotlin. - companion object — one object per class, the static
Companionfield, what Java sees without@JvmStatic, and why a companion can implement an interface. - Anonymous objects — an expression that yields a fresh instance on every evaluation, several supertypes at once, scope capture, and the leak through the outer
this.
Common Mistakes and Traps
| Mistake | Consequence |
|---|---|
Reading sealed as merely "the class cannot be instantiated" | abstract does that too; the point of sealed is a closed subtype set the compiler knows |
| Declaring a direct subtype of a sealed type in another module | It does not compile: direct subclasses must sit in the same package and the same compilation module |
Expecting a when over a sealed type to still need an else | It does not — and an else actually switches off the completeness check |
Adding else "to keep the compiler quiet" | You have opted out of exhaustiveness: a new subtype silently lands in else at runtime |
| Believing that because it compiles, no new subtype can slip through | With else present the compiler checks nothing — a green build guarantees nothing |
Thinking a sealed interface is open to any implementer | Its implementation set is just as closed: same package, same module |
| Expecting a subtype to extend a sealed class and another class | Implementation inheritance is single — that is exactly what sealed interface exists for |
Expecting constructor state on a sealed interface | An interface holds no state: shared state across the cases is the reason to pick a sealed class |
Looking for permits or non-sealed in Kotlin | Neither exists: the set is inferred from package and module; permits is a Java 17 requirement, and Kotlin has no non-sealed hatch at all |
Treating an object declaration as a mere namespace for functions | It is a full instance: it can hold state, implement an interface, and be passed around as a value |
Adding @Volatile and synchronized "to make an object thread-safe" | Wasted work: INSTANCE is assigned in the static initialiser, and the JVM runs that exactly once |
Expecting the object to be built when the process starts | Initialisation is lazy — on the first access to the class |
Holding a Context or an Activity inside an object | A leak for the lifetime of the process: a singleton has no moment at which it is released |
Calling a companion object a static member of the class | It is an object in a static Companion field; from Java, without @JvmStatic, the call reads Foo.Companion.bar() |
Declaring two companion objects in one class | It does not compile: exactly one companion is allowed (named or not) |
Expecting an anonymous object to hand back the same instance on every evaluation | It is an expression: every evaluation is a fresh object — inside a loop you create as many as there are iterations |
| Thinking an anonymous object may implement only one interface | It can implement several at once (and extend a class besides) — precisely what sets it apart from a SAM lambda |
| Applying Java's effectively-final rule to captured locals in Kotlin | Kotlin captures a var and lets you mutate it — and the outer this gets captured along with it |
Interview relevance
This topic is popular because it instantly shows whether a candidate reasons from an execution model or from a glossary. Asked "what does sealed give you?", the weak answer is "you cannot subclass it from outside"; the strong one is "the compiler knows the complete list of subtypes, so a when is checked for completeness and a new case breaks the build for every consumer". Same for the other half: "object is a singleton" merely repeats the word, whereas "an object compiles to a class with a private constructor and a static INSTANCE assigned in the static initialiser, so it is lazy and thread-safe without a single lock" is an answer.
Typical checks:
- How
sealeddiffers fromabstractand fromenum, and where direct subclasses must live. - When to reach for a
sealed interfaceand when for asealed class. - What happens to an existing
whenwhen a new subtype joins the hierarchy — and what changes if it has anelse. - What an
objectdeclaration compiles to, and where its thread safety comes from. - Why double-checked locking is unnecessary in Kotlin, and what it rebuilds by hand.
- How a
companion objectdiffers from Java static members, and what@JvmStaticdoes. - How many instances an anonymous
objectinside a loop produces, and what it captures.
Common wrong answer: "I added an else, so the when is safer now — no case can go unhandled." The truth is the exact opposite. Without else the compiler must prove every subtype is covered, and adding Pending to the hierarchy will not compile until you handle it explicitly. With else the check is off: Pending silently takes the "everything else" branch and is shown to the user as a failure. The second classic miss is "a companion object is the same as Java static". From Java, without @JvmStatic, the call is written Foo.Companion.bar() — and a companion, unlike a static, is an object: it can implement an interface, be passed to a function, and receive an extension function.