skip to content

Why does a @Serializable sealed hierarchy work out of the box, while making an open/abstract base polymorphic requires a SerializersModule? Walk through registering the open case.

level: middleimportance: must knowfreq 60%

answer

  1. sealed = closed world => auto register
  2. open/interface = open world => manual SerializersModule
  3. polymorphic(Base::class){ subclass(...) }
  4. @Polymorphic on the base
  5. attach via Json { serializersModule = ... }

basics

~20 s

For sealed types the compiler knows every subclass, so it registers them for you. For open or abstract types, subclasses can live anywhere, so you must list them yourself in a SerializersModule with polymorphic { subclass(...) }.

solid answer

~40 s

A sealed class restricts subclasses to the same module, so the kotlinx.serialization plugin enumerates them at compile time and builds the polymorphic dispatch automatically. An open class or interface can be extended by code the plugin can't see, so the library can't enumerate subtypes — you must register them at runtime. You build a SerializersModule with a polymorphic(BaseType::class) {} block calling subclass(Concrete::class, Concrete.serializer()) for each, then attach it via Json { serializersModule = module }. The base must be annotated @Polymorphic (or referenced via PolymorphicSerializer). Unregistered subtypes throw SerializationException at runtime. Interfaces need this even if sealed-like, and you can register a default via defaultDeserializer for unknown discriminators.

code

kotlin · 8 lines
kotlin
val module = SerializersModule {
    polymorphic(Message::class) {
        subclass(Text::class)
        subclass(Image::class)
        defaultDeserializer { Text.serializer() } // fallback for unknown discriminator
    }
}
val json = Json { serializersModule = module }

go deeper

for a junior

Knows sealed is automatic and open needs extra setup, even if fuzzy on the API.

for a middle

Writes a correct SerializersModule with polymorphic{}/subclass and attaches it to Json.

for a senior

Explains closed-vs-open-world rationale and uses defaultDeserializer for unknown discriminators.

for a principal

Weighs cross-module extensibility (open) vs exhaustiveness+auto-registration (sealed) when designing shared wire libraries.

## The core difference: closed vs open world - A **sealed** class/interface is a **closed** set: all direct subtypes are known at compile time, in the same module. The kotlinx.serialization **compiler plugin** enumerates them and wires up polymorphic dispatch automatically — **zero runtime registration**. - An **open**/`abstract` class or a non-sealed **interface** is an **open** set: anyone, anywhere (other modules) can subtype it. The library cannot know the subtypes, so **you** must register them in a `SerializersModule`. ## Registering the open case ```kotlin import kotlinx.serialization.* import kotlinx.serialization.modules.* import kotlinx.serialization.json.Json @Serializable @Polymorphic abstract class Message @Serializable @SerialName("text") data class Text(val body: String) : Message() @Serializable @SerialName("image") data class Image(val url: String) : Message() val module = SerializersModule { polymorphic(Message::class) { subclass(Text::class, Text.serializer()) subclass(Image::class, Image.serializer()) } } val json = Json { serializersModule = module } val s = json.encodeToString<Message>(Text("hi")) // {"type":"text","body":"hi"} ``` ## Key pieces - `@Polymorphic` on the base (or use `PolymorphicSerializer(Message::class)` explicitly) tells the library to use polymorphic, discriminator-based handling. - `SerializersModule { polymorphic(Base::class) { subclass(...) } }` is the runtime registry. - `Json { serializersModule = ... }` attaches it. **You serialize through the base type** so the polymorphic serializer is chosen. - The `subclass(...)` overload can take just `subclass(Text::class)` if the serializer is inferable. ## Unknown subtypes / defaults - Encoding an unregistered subtype, or decoding an unknown discriminator value, throws `SerializationException`. - You can install a fallback with `defaultDeserializer { ... }` (and `default(...)` builder) inside the `polymorphic` block to handle unknown discriminators gracefully — useful for schema evolution. ## Why sealed is preferred for wire models Sealed gives compile-time exhaustiveness (the `when` over subtypes is checked) **and** automatic registration. Open hierarchies trade that for extensibility across module boundaries at the cost of manual, easy-to-forget registration. ## Key APIs/keywords `sealed`, `abstract`/`open`, `@Polymorphic`, `PolymorphicSerializer`, `SerializersModule`, `polymorphic`, `subclass`, `Json { serializersModule = ... }`, `SerializationException`.

  • Can an interface be the polymorphic base?
    Yes. A sealed interface auto-registers; a regular interface needs manual polymorphic{} registration just like an open class.
  • What happens if you decode a discriminator value not registered in the module?
    SerializationException, unless you registered a defaultDeserializer fallback in the polymorphic block.

Sealed is a guest list the venue already has; open is an open-door event where you must hand the bouncer each name yourself.

saying these in an interview costs you the question

  • Saying sealed and open are registered the same way
  • Forgetting @Polymorphic / PolymorphicSerializer on the open base
  • Registering subclasses but never attaching the module to Json
  • Claiming SerializersModule is needed for sealed classes too

context