Why must you distrust the polymorphic type discriminator in JSON, and how does sealed-class polymorphism plus a controlled SerializersModule keep deserialization safe?
answer
- Discriminator ('type') is untrusted type-selection input
- Sealed = compiler-closed subtype set, fail-closed on unknown
- Open polymorphic = explicit SerializersModule allowlist only
- ignoreUnknownKeys/isLenient ≠ type safety
- Resolved subtype still needs init {} validation
basics
~20 sA 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 sPolymorphic 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@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
Knows a 'type' field picks the subtype and that sealed classes give a fixed list; may not detail module registration.
Explains sealed vs open polymorphism and that unknown discriminators throw rather than instantiate arbitrarily.
Treats discriminator as untrusted, prescribes closed set / explicit allowlist, debunks ignoreUnknownKeys, layers per-subtype validation and boundary handling.
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