skip to content

Why must you distrust the polymorphic type discriminator in JSON, and how does sealed-class polymorphism plus a controlled SerializersModule keep deserialization safe?

level: seniorimportance: should knowfreq 45%

answer

  1. Discriminator ('type') is untrusted type-selection input
  2. Sealed = compiler-closed subtype set, fail-closed on unknown
  3. Open polymorphic = explicit SerializersModule allowlist only
  4. ignoreUnknownKeys/isLenient ≠ type safety
  5. Resolved subtype still needs init {} validation

basics

~20 s

A JSON 'type' field tells the decoder which subtype to build. If attackers can pick any type, that is dangerous. With sealed classes the choices are a fixed, known list, so an unknown type just fails safely.

solid answer

~50 s

Polymorphic deserialization resolves a class discriminator (default key `type`, configurable via `@JsonClassDiscriminator`) to a concrete subtype's serializer. The discriminator is attacker-controlled input, so it must resolve only within a *closed, registered* set. **Sealed-class polymorphism** is the safe default: the compiler knows all subclasses, generates a discriminated serializer for them, and any unregistered/unknown discriminator throws `SerializationException` instead of instantiating something arbitrary. **Open polymorphism** (`polymorphic(Base::class) { subclass(...) }` in a `SerializersModule`) is fine only if you register an explicit allowlist; never expose a base that could resolve to broad classpath types. Pitfalls: enabling `ignoreUnknownKeys`/lenient parsing does not make an unknown *type* safe; default polymorphism on an interface without a module fails to decode; and you should still validate the resolved subtype's invariants in its `init {}`. The discriminator decides *which* type, not whether its data is valid.

code

kotlin · 11 lines
kotlin
@Serializable
sealed interface Command {
    @Serializable @SerialName("Pay")    data class Pay(val cents: Long) : Command
    @Serializable @SerialName("Refund") data class Refund(val cents: Long) : Command
}

fun decode(input: String): Command? = try {
    Json.decodeFromString<Command>(input) // "type":"Pay"|"Refund" only
} catch (e: SerializationException) {
    null // unknown discriminator or malformed -> fail closed
}

go deeper

for a junior

Knows a 'type' field picks the subtype and that sealed classes give a fixed list; may not detail module registration.

for a middle

Explains sealed vs open polymorphism and that unknown discriminators throw rather than instantiate arbitrarily.

for a senior

Treats discriminator as untrusted, prescribes closed set / explicit allowlist, debunks ignoreUnknownKeys, layers per-subtype validation and boundary handling.

for a principal

Designs the polymorphic contract for security + evolution: allowlist governance, DoS/depth limits, discriminator-mode choices, cross-service compatibility.

## What the discriminator is In polymorphic JSON, an object encodes *which subtype it is* via a **class discriminator** — a property whose string value names the subtype. kotlinx.serialization's default key is `"type"`; you can change it with `@JsonClassDiscriminator("kind")` or switch to array/wrapper form via `JsonConfiguration`/`classDiscriminatorMode`. Example payload: `{"type":"Sms","number":"+1..."}`. This value is **untrusted input**. The decoder maps it to a registered `KSerializer` and builds that subtype. If the mapping is unbounded, the discriminator becomes a *type-selection primitive* — the same dangerous shape as Jackson default typing. ## Sealed-class polymorphism: closed set, safe by construction ```kotlin import kotlinx.serialization.* import kotlinx.serialization.json.Json @Serializable sealed interface Notification { @Serializable @SerialName("Sms") data class Sms(val number: String) : Notification @Serializable @SerialName("Email") data class Email(val address: String) : Notification } val json = Json { classDiscriminator = "type" } val n = json.decodeFromString<Notification>(input) // resolves only to Sms or Email ``` Because the type is `sealed`, the **compiler enumerates every subtype** and the plugin builds a `SealedClassSerializer` registering exactly `Sms` and `Email`, keyed by their `@SerialName`. An input with `"type":"EvilGadget"` (or any unlisted name) throws `SerializationException` — there is no path to instantiate anything outside the closed set. This is the recommended pattern. ## Open polymorphism: safe ONLY with an explicit allowlist For non-sealed hierarchies you register subtypes in a `SerializersModule`: ```kotlin val module = SerializersModule { polymorphic(Notification::class) { subclass(Sms::class) subclass(Email::class) } } val json = Json { serializersModule = module } ``` This is safe *iff* the registered set is a deliberate allowlist. Danger appears if you register an over-broad base, or attempt 'register everything' patterns. Unregistered discriminators throw rather than instantiate — keep it that way. ## Common misconceptions / pitfalls - **`ignoreUnknownKeys = true`** only tolerates unknown *properties*; it does NOT make an unknown *discriminator* acceptable. - **`isLenient`/lenient parsing** relaxes JSON syntax, not type safety. - Decoding a polymorphic base **without** a sealed type or a registered module fails (`SerializationException: Class 'X' is not registered`) — fail-closed, which is good. - Resolving the right subtype does not validate it; each subtype still needs its own `init {}` invariants. - Watch a hostile discriminator combined with **recursive/nested** polymorphism for DoS (deep nesting); cap with input size/depth limits at the boundary. ## Summary Treat the discriminator as untrusted: constrain resolution to a **closed set** (sealed by default; an explicit `SerializersModule` allowlist otherwise), let unknown types **fail with `SerializationException`**, and still validate the chosen subtype's values.

  • What happens if the JSON discriminator names a subtype that is not registered/sealed-known?
    Decoding throws SerializationException (fail-closed). It never falls back to instantiating an arbitrary class, which is the safety guarantee.
  • Does ignoreUnknownKeys = true help with an unexpected polymorphic type?
    No. That flag only skips unknown object *properties*; an unknown class *discriminator* still throws. Type resolution and unknown-property tolerance are independent.

The discriminator is a key to a keyring: sealed gives you a small fixed keyring; open typing without an allowlist is a master key that opens any door on the classpath.

saying these in an interview costs you the question

  • Trusting the 'type' field and registering an open/broad base 'to be flexible'
  • Thinking ignoreUnknownKeys or isLenient affects discriminator safety
  • Assuming a resolved subtype is automatically valid (skipping its init {})
  • Not handling SerializationException for unknown discriminators
  • Preferring open polymorphism over sealed without an allowlist justification

context