skip to content

What are the consequences of adding a new subtype to a public sealed type, and how do sealed types interact with generic type parameters and variance?

level: principalimportance: nice to knowfreq 20%

answer

  1. Adding a subtype breaks exhaustive consumers
  2. Public sealed case-set is part of the API contract
  3. sealed interface Result<out T> — covariance
  4. Nothing is bottom type → no-data cases use Result<Nothing>
  5. Variance affects assignability, not the closed set

basics

~20 s

Adding a subtype to a public sealed type is a breaking change for code that handles every case, because that code now misses one. Sealed types can be generic, and you can make the type parameter covariant with out to allow flexible subtyping.

solid answer

~40 s

Because consumers can switch over the complete subtype set, adding a new subtype to a **public** sealed type is a **source-breaking change**: any code that exhaustively handled the old cases now fails to compile (or silently misroutes). This is a deliberate trade-off — the compiler forces every consumer to acknowledge the new case, which is great inside one codebase but risky across a published library boundary. Sealed types can be **generic**: `sealed interface Result<out T>` makes `T` covariant via `out`, so `Result<Cat>` is a `Result<Animal>`; a common pattern is a no-data subtype like `object Loading : Result<Nothing>` exploiting `Nothing` as the bottom type. You can also constrain or use `in` for contravariance where it fits. Keep the closed set and variance annotations consistent with how subtypes carry or omit the parameter.

code

kotlin · 4 lines
kotlin
sealed interface Tree<out T>
data class Leaf<out T>(val value: T) : Tree<T>
data class Branch<out T>(val left: Tree<T>, val right: Tree<T>) : Tree<T>
object Empty : Tree<Nothing> // bottom type reused for all T

go deeper

for a junior

May not see the compatibility angle; can at most note that new cases need new handling.

for a middle

Recognizes that new subtypes force consumers to update and can write a simple generic sealed type.

for a senior

Explains source/binary breakage across a library and uses out + Nothing idiomatically.

for a principal

Treats the case-set as a versioned API contract, reasons about variance trade-offs, and designs hierarchies for safe evolution.

## Evolution: adding a subtype is breaking The closed set is a contract. Code elsewhere may rely on knowing *all* the cases. So introducing a new direct subtype: - **Breaks exhaustive consumers at compile time** within the same module — they must add handling. This is usually *desirable*: the compiler points you at every place to update. - Across a **published library**, it is a **binary/source compatibility break** for downstream code that exhaustively handled the old set. Treat the subtype list of a public sealed type as part of the API contract. ```kotlin sealed interface Command object Start : Command object Stop : Command // Adding `object Pause : Command` forces every exhaustive consumer to update. ``` **Design implication:** expose a sealed type publicly only when you intend the case set to be stable, or you accept that adding cases is a major version bump. ## Generics and variance Sealed types are frequently generic. Variance is declared on the type parameter: - **`out T` (covariant):** producers of `T`. Enables `Result<Cat>` to be used where `Result<Animal>` is expected. - **`in T` (contravariant):** consumers of `T`. ```kotlin sealed interface Result<out T> { data class Ok<out T>(val value: T) : Result<T> data class Err(val message: String) : Result<Nothing> object Loading : Result<Nothing> } ``` ### The `Nothing` trick `Nothing` is Kotlin's **bottom type** — a subtype of every type. A case with no payload (`Err`, `Loading`) implements `Result<Nothing>`. Because of covariance (`out`), `Result<Nothing>` is assignable to `Result<T>` for any `T`, so a single `Loading` object serves every parameterization — no need to parameterize the no-data case. ### Constraints You can bound the parameter: `sealed interface Box<T : Number>`. Subtypes must respect the bound. Mixing variance and the closed set is fine as long as each subtype declares its supertype with a compatible argument (e.g. `Result<T>` or `Result<Nothing>`). ## Putting it together - Closed set + generics + `out`/`Nothing` is the idiom behind reusable `Result`/`Option`-style types. - The closedness still holds: all subtypes remain in the same module and package; variance only affects *assignability*, not who may subclass.

  • Why can a single `object Empty : Tree<Nothing>` serve every Tree<T>?
    Nothing is a subtype of every type and Tree is covariant (out T), so Tree<Nothing> is a subtype of Tree<T> for all T — one instance fits all.
  • Is adding a subtype always bad?
    No — within one codebase the compile errors are a feature, guiding you to update every consumer. It's mainly dangerous across a published, exhaustively-consumed API.

Adding a subtype is like adding a new exam question after grading began — everyone who 'finished' must come back and answer it.

saying these in an interview costs you the question

  • Saying adding a subtype is always safe / non-breaking
  • Not knowing Nothing is the bottom type
  • Putting out/in on the wrong side or claiming variance changes who can subclass
  • Parameterizing a no-data case instead of using Result<Nothing>

context