Engineering

kotlinx.serialization is compile-time, which is why your polymorphism fails at runtime

No reflection on Native, so the serializer is generated. Sealed hierarchies work; open polymorphism needs a module you register by hand.

MobileByteSensei 9 min read
kmpserializationarchitecture

TL;DR

Gson and Jackson find your types at runtime by reflection. kotlinx.serialization cannot — there is no reflection on Kotlin/Native — so it generates a serializer at compile time from the @Serializable annotation.

That one difference explains the whole API. A sealed hierarchy serialises polymorphically with no configuration, because the compiler can enumerate the subtypes. An open hierarchy cannot, because the compiler cannot — so you register each subtype by hand in a SerializersModule, and forgetting one is a runtime failure on the first payload that contains it.

Prefer sealed. When you cannot, treat the module as part of the type's definition rather than as configuration.

The case this post is about

A sync endpoint returning a list of heterogeneous events. The Kotlin model:

// commonMain
@Serializable
sealed interface SyncEvent {
    @Serializable @SerialName("item_added")   data class ItemAdded(val id: String) : SyncEvent
    @Serializable @SerialName("item_removed") data class ItemRemoved(val id: String) : SyncEvent
}

This works perfectly, on every target, with no setup:

[ {"type":"item_added","id":"a1"}, {"type":"item_removed","id":"a1"} ]

Then a plugin system arrived. Third-party modules contribute their own event types, so the hierarchy could no longer be sealed — a sealed type's subtypes must live in the same module.

// commonMain
@Serializable
abstract class PluginEvent { abstract val pluginId: String }

// in a separate module
@Serializable @SerialName("calendar_sync")
data class CalendarSync(override val pluginId: String, val count: Int) : PluginEvent()

It compiled. It even serialised, in the test that used CalendarSync directly.

The symptom

A crash on the first payload that actually exercised the polymorphism:

kotlinx.serialization.SerializationException:
  Class 'CalendarSync' is not registered for polymorphic serialization
  in the scope of 'PluginEvent'.
  To be registered automatically, class 'CalendarSync' has to be '@Serializable',
  and the base class 'PluginEvent' has to be sealed and '@Serializable'.
  Alternatively, register the serializer for 'CalendarSync' explicitly
  in a corresponding SerializersModule.

Two things about this error are worth noticing, because they are the whole post.

It is an unusually good error message. It names the class, the scope, both conditions, and the alternative. Most of my wasted time was not from the message being unclear.

It appeared only on deserialisation of the base type. Json.encodeToString(CalendarSync(...)) — the test I had written — uses the concrete serializer, which exists. The failure needs a call site typed as PluginEvent, which only the real sync path had.

Why the obvious fix failed

"Add @Serializable to the subclass." Already there. The annotation generates a serializer for the class; it does not enter that serializer into any registry, because there is no registry until you make one.

"It must need ignoreUnknownKeys / a classDiscriminator." Configuration flailing. I set both and learned nothing — the discriminator controls what the type field is called, not whether the subtype is resolvable.

"Use @JsonClassDiscriminator on the base." Same category. It customises an existing mechanism rather than supplying the missing registration.

"Make the base sealed again." The correct answer when you can, and here the entire requirement was that third-party modules contribute subtypes. Sealed means "all subtypes in this module", which is precisely the constraint being rejected.

"Switch to a reflection-based serializer for this one type." There is no reflection on Kotlin/Native. On the JVM this would work and would therefore pass CI on two targets and fail on two, which is the shape of a much worse bug than the one I had.

The actual mechanism

kotlinx.serialization is a compiler plugin. @Serializable makes it generate a KSerializer implementation for that class at compile time — a serialize/deserialize pair that reads and writes the declared fields by position. No runtime type inspection anywhere.

That design is forced: Kotlin/Native and Kotlin/Wasm have no kotlin.reflect worth the name, so anything that must work on all targets cannot discover types at runtime.

It also means the generated serializer reads declared fields by position, which has a consequence people meet the first time they refactor a DTO: field order in the class is part of the binary contract for formats that encode positionally, and field presence is part of every contract. Reordering two properties in a @Serializable class is a no-op in JSON and a silent data-corruption bug in ProtoBuf or CBOR, where the same bytes now decode into different fields — with no error, because the types happen to match.

@Serializable data class Point(val x: Int, val y: Int)   // ProtoBuf: 1=x, 2=y
@Serializable data class Point(val y: Int, val x: Int)   // ProtoBuf: 1=y, 2=x — old data inverts

Which is the general shape of a compile-time mechanism: the plugin is deriving a contract from the class declaration, so every edit to that declaration is an edit to the wire format, including edits that look purely cosmetic. A reflection-based library would have the same problem; the difference is that the plugin makes it decidable at build time, which is what the explicit @ProtoNumber annotations in the fix below are for.

Polymorphism then splits cleanly into two cases.

Sealed: the compiler knows the subtypes. A sealed hierarchy's subtypes are, by definition, all visible in one compilation unit. The plugin enumerates them, generates a serializer for the base that switches on the discriminator, and resolution is complete at compile time. This is why it needs no configuration — and it is a stronger guarantee than reflection gives you, because adding a subtype updates the generated serializer automatically.

Open: the compiler cannot know the subtypes. That is the point of open/abstract — a subtype may be in a module that did not exist when the base was compiled. There is no compile-time set to enumerate, so the mapping from "calendar_sync" to a serializer must be supplied at runtime. SerializersModule is that supply.

Two polymorphic cases: a sealed hierarchy whose subtypes are all visible in one compilation, so the compiler plugin generates a complete base serializer with no configuration; and an open hierarchy whose subtypes may live in modules compiled later, so no compile-time set exists and the discriminator-to-serializer mapping must be registered by hand at runtime

Which gives the rule:

kotlinx.serialization can resolve exactly what the compiler could see. Sealed is a promise that it saw everything. open is a promise that it did not — so every open hierarchy owes a runtime registration, one entry per subtype, and the compiler cannot check you wrote them all.

The fix

Register the subtypes, and make the registration live with the type rather than in application setup.

// in the module that DEFINES CalendarSync — not in app wiring
val calendarEventsModule = SerializersModule {
    polymorphic(PluginEvent::class) {
        subclass(CalendarSync::class, CalendarSync.serializer())
    }
}
// commonMain — the format composes the contributed modules
fun buildJson(plugins: List<SerializersModule>) = Json {
    serializersModule = SerializersModule {
        include(coreEventsModule)
        plugins.forEach { include(it) }
    }
    classDiscriminator = "type"
    ignoreUnknownKeys = true
}

Three decisions in there are the actual content.

The module ships with the subtype, not with the app. A plugin that defines a type and does not register it is incomplete, and co-locating them makes that obvious at review time. Registration in a central AppModule means every new plugin requires an edit to a file its author does not own — which is how a subtype gets forgotten.

ignoreUnknownKeys = true is deliberate, and it is not a workaround here. An older client receiving a newer payload should tolerate extra fields. It does not help with an unknown subtype, which is a different failure and should stay loud.

Unknown subtypes get an explicit fallback, chosen rather than crashed into. An old client will meet an event type that did not exist when it shipped:

polymorphic(PluginEvent::class) {
    defaultDeserializer { UnknownPluginEvent.serializer() }
}

@Serializable @SerialName("unknown")
data class UnknownPluginEvent(override val pluginId: String = "") : PluginEvent()

Now a forward-compatible payload degrades to a skippable event instead of failing the whole sync batch. That is a product decision — and the reason to make it explicitly is that the alternative is a crash in the field on the day someone else ships a plugin.

Where you can keep it sealed, keep it sealed. The core events stayed sealed and need none of this. Only the genuinely extensible boundary pays the cost, which is the right place for it.

How we proved it

The bug is "a registration is missing" and the dangerous version is "a registration is missing for a subtype nobody tested". So the test has to be about round-tripping through the base type, which is what my original test failed to do.

// commonTest
@Test
fun everyRegisteredSubtypeRoundTripsThroughTheBase() {
    val json = buildJson(listOf(calendarEventsModule))
    val events: List<PluginEvent> = listOf(
        CalendarSync(pluginId = "cal", count = 3),
        // one entry per registered subtype
    )
    events.forEach { e ->
        val encoded = json.encodeToString(PluginEvent.serializer(), e)   // ← base, not concrete
        val decoded = json.decodeFromString(PluginEvent.serializer(), encoded)
        assertEquals(e, decoded)
    }
}

PluginEvent.serializer() rather than CalendarSync.serializer() is the entire difference between a test that catches this and the one I had written.

Results across the matrix, with and without the registration:

without calendarEventsModule
  jvmTest  FAILED · androidUnitTest FAILED · iosSimulatorArm64Test FAILED · wasmJsTest FAILED

with calendarEventsModule
  jvmTest  PASSED · androidUnitTest PASSED · iosSimulatorArm64Test PASSED · wasmJsTest PASSED

Four targets failing identically is itself informative: this is not a platform difference, it is a missing registration, and the uniformity confirms the diagnosis.

The forward-compatibility path needed its own check, with a payload the client could not have known about:

input:  {"type":"weather_sync","pluginId":"wx","tempC":12}
before: SerializationException — the whole batch fails
after:  UnknownPluginEvent(pluginId="") — logged and skipped, batch completes

One honest limit. The round-trip test enumerates the subtypes by hand, so it catches a subtype that was registered-but-broken and not one that was never registered and never added to the test. Closing that properly needs the list to come from somewhere single-sourced — a KSP processor over the annotations, which we have not built. Until then the per-module co-location is a convention, not an enforcement, and I would rather say so than imply the gap is closed.

What this generalizes to

Reflection and codegen trade runtime discovery for compile-time completeness. Neither is strictly better. Reflection finds any type and can fail on obfuscation, stripping, or a platform without it. Codegen cannot find a type nobody told it about, and in exchange it works everywhere and is checkable. Knowing which one you have tells you where your failures will appear.

"Open" means the compiler cannot help you. Every extensibility point you open is a place where a check moves from compile time to runtime. That is often the right trade for a plugin boundary and almost never the right trade inside your own module.

A round-trip test must use the declared type, not the concrete one. My passing test exercised the one path that was never going to break. Whenever serialisation is polymorphic, the test's static type has to be the base — the same discipline as testing through an interface rather than an implementation.

Design the unknown case before it arrives. Any extensible format will eventually be read by a client older than the payload. Deciding now whether that is a skipped event, a failed batch, or a prompt to update is cheap. Discovering it in production, on someone else's release schedule, is not.

Keep reading

This is part of 100 Days of Kotlin Multiplatform — the boundaries, the sharp edges, and the things that only show up on the second target. Follow MobileByteSensei for the rest.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS