skip to content

How do you handle unknown or unregistered subtypes in a polymorphic SerializersModule, and what are the failure modes?

level: seniorimportance: should knowfreq 35%

answer

  1. defaultDeserializer { name -> ... } for decode fallback
  2. default { kClass -> ... } for encode fallback
  3. Unregistered => SerializationException at runtime
  4. UnknownEvent pattern for forward compatibility
  5. ignoreUnknownKeys is a separate concern

basics

~10 s

By default, an unknown type throws an error. You can register a fallback with defaultDeserializer for decoding and default for encoding, so unexpected types map to a safe placeholder instead of crashing.

solid answer

~40 s

Inside polymorphic(Base::class) { } you can install fallbacks: defaultDeserializer { name -> ... } returns a DeserializationStrategy when the incoming discriminator value isn't registered, and default { kClass -> ... } supplies a SerializationStrategy for an actual subtype instance you didn't register. Without them, an unregistered subtype throws SerializationException ('not registered for polymorphic serialization') at runtime — both directions. A common pattern is an UnknownEvent fallback class capturing the raw discriminator so forward-compatible clients don't break when a server adds new types. Note Json.decodeFromString also needs ignoreUnknownKeys to tolerate extra properties — that's orthogonal to the polymorphic default. The defaults only resolve the type-selection step, not unknown fields within a known type.

code

kotlin · 14 lines
kotlin
@Serializable sealed interface Event
@Serializable @SerialName("login") data class Login(val user: String) : Event
@Serializable data class UnknownEvent(val raw: String = "?") : Event

val json = Json {
    serializersModule = SerializersModule {
        polymorphic(Event::class) {
            subclass(Login::class)
            defaultDeserializer { _ -> UnknownEvent.serializer() }
        }
    }
}
// Payload with a future type decodes to UnknownEvent instead of throwing
json.decodeFromString<Event>("""{"type":"reaction","raw":"x"}""")

go deeper

for a junior

Knows unknown types throw and that there is some fallback mechanism.

for a middle

Distinguishes the runtime exception and can wire a basic defaultDeserializer.

for a senior

Correctly separates default vs defaultDeserializer, builds the UnknownEvent forward-compat pattern, and isolates ignoreUnknownKeys.

for a principal

Designs resilient evolution strategies (catch-all + telemetry) across services with independent deploy cadences.

## Two distinct fallbacks Inside the `polymorphic` block: ```kotlin SerializersModule { polymorphic(Event::class) { subclass(Login::class) // decode fallback: discriminator value not in the table defaultDeserializer { name -> UnknownEvent.serializer() } } } ``` - **`defaultDeserializer { name -> DeserializationStrategy }`** — invoked during *decoding* when the discriminator string `name` matches no registered subclass. Return a strategy (often for a catch-all class) to avoid throwing. - **`default { kClass -> SerializationStrategy? }`** — invoked during *encoding* when you try to serialize an instance whose concrete `kClass` isn't registered. Return a strategy or `null` (which then throws). ## Failure modes without fallbacks - Encoding an unregistered subtype: `SerializationException: class '...' is not registered for polymorphic serialization in the scope of '...'`. - Decoding an unknown discriminator: same family of `SerializationException`. - These are **runtime** errors — there's no compile-time safety for open hierarchies. ## Forward-compatibility pattern ```kotlin @Serializable data class UnknownEvent(val type: String) : Event val module = SerializersModule { polymorphic(Event::class) { subclass(Login::class) defaultDeserializer { UnknownEvent.serializer() } } } ``` Now a payload with a new `type` your client doesn't know about decodes into `UnknownEvent` instead of crashing — crucial for rolling deploys. ## What defaults do NOT do - They don't tolerate **unknown fields** inside a known type — that's `Json { ignoreUnknownKeys = true }`. - They don't change the discriminator key — that's `classDiscriminator`. - A discriminator value collision with a real property still throws regardless. ## Decoding mechanics Json reads the discriminator first (it may buffer the object to find it, depending on `classDiscriminatorMode`/streaming), selects the strategy (registered or default), then deserializes the body with it.

  • Does defaultDeserializer also help when a known type has an extra unexpected field?
    No. That's handled by Json { ignoreUnknownKeys = true }. defaultDeserializer only chooses a strategy when the discriminator value itself is unregistered.
  • What's the difference between default and defaultDeserializer in the polymorphic builder?
    defaultDeserializer is the decode-side fallback (unknown discriminator string); default is the encode-side fallback (instance of an unregistered concrete class).

saying these in an interview costs you the question

  • Conflating polymorphic defaults with ignoreUnknownKeys
  • Expecting compile-time errors for missing open-hierarchy registration
  • Using default { } when they mean defaultDeserializer { } (or vice versa)
  • Assuming a fallback makes the data round-trip losslessly
  • Not realizing both encode and decode can throw on unregistered types

context