What exactly does the kotlin-serialization compiler plugin generate for a @Serializable class, and why is 'no reflection' a key benefit?
answer
- Generates KSerializer + SerialDescriptor
- serialize() drives Encoder, deserialize() drives Decoder
- $serializer object + serializer() accessor
- no reflection -> multiplatform + fast + shrinker-friendly
- descriptor = compile-time schema
basics
~20 sFor each @Serializable class the plugin generates the code that knows how to read and write that class's fields. Because the code is built at compile time, it doesn't inspect classes with reflection at runtime, so it's faster and works where reflection is limited.
solid answer
~40 sThe plugin synthesizes a `KSerializer<T>` implementation for the class — usually a nested `$serializer` object plus a generated `companion`'s `serializer()` function. It produces three things: a `SerialDescriptor` (the shape: property names, kinds, optionality, nullability), a `serialize(encoder, value)` that drives an `Encoder` over each property, and a `deserialize(decoder)` that drives a `Decoder` and reconstructs the object via the constructor. Because all of this is plain generated bytecode, there is no runtime reflection: no `Class.getDeclaredFields()`, no field access by reflection. Benefits: faster cold start and steady-state, smaller/predictable behavior on R8/ProGuard (no keep rules to preserve reflected members), and it works on **Kotlin/Native and Kotlin/JS** where JVM reflection isn't available — that's the whole point of being multiplatform.
code
kotlin · 16 lines@Serializable
data class User(val name: String, val age: Int)
// Conceptually the plugin emits something like:
// object `User$serializer` : KSerializer<User> {
// override val descriptor = buildClassSerialDescriptor("User") {
// element<String>("name"); element<Int>("age")
// }
// override fun serialize(e: Encoder, v: User) {
// val c = e.beginStructure(descriptor)
// c.encodeStringElement(descriptor, 0, v.name)
// c.encodeIntElement(descriptor, 1, v.age)
// c.endStructure(descriptor)
// }
// override fun deserialize(d: Decoder): User { /* read elements, call ctor */ }
// }go deeper
Knows it's reflection-free and faster; can state @Serializable triggers code generation.
Can name the generated artifacts (KSerializer, SerialDescriptor, serialize/deserialize) and explain encoder/decoder flow.
Connects no-reflection to concrete benefits: multiplatform support, R8 shrinking, cold-start, and explains reified serializer resolution.
Reasons about descriptor caching, codegen impact on binary size and compile time, and tradeoffs vs reflective frameworks at architecture scale.
## What 'compile-time codegen' means here Unlike Jackson or Gson, which at runtime use **reflection** (inspecting class metadata to discover fields/getters), kotlinx.serialization resolves everything during compilation. The `kotlin("plugin.serialization")` compiler plugin runs as part of `kotlinc` and, for each `@Serializable` type, emits code. ## What gets generated For `@Serializable class User(val name: String, val age: Int)` the plugin synthesizes roughly: - A nested generated serializer (commonly `User.$serializer`) implementing `KSerializer<User>`. - A `serializer()` accessor (so `User.serializer()` returns the `KSerializer`). - A **`SerialDescriptor`** built once: it lists element names (`name`, `age`), their kinds, whether they're optional (have defaults) and nullable. Think of it as a compile-time schema. - **`serialize(encoder: Encoder, value: User)`**: opens a structured `CompositeEncoder` via `beginStructure`, then `encodeStringElement(descriptor, 0, value.name)`, `encodeIntElement(descriptor, 1, value.age)`, then `endStructure`. - **`deserialize(decoder: Decoder): User`**: opens `beginStructure`, loops `decodeElementIndex` to read each present element, then calls the **constructor**. ```kotlin @Serializable data class User(val name: String, val age: Int) // Json.encodeToString(User(...)) calls the generated User.$serializer ``` ## Why 'no reflection' matters - **Performance / startup**: no reflective field discovery on first use; descriptors are precomputed. Better cold start (important for serverless/Android). - **Multiplatform**: Kotlin/Native and Kotlin/JS have no full JVM reflection. Compile-time codegen is what makes the library work on all targets. - **Shrinking (R8/ProGuard)**: reflection-based libs need keep rules so members aren't stripped/renamed. Here the serializer references members directly, so it survives shrinking far more predictably. - **Type safety**: the descriptor and (de)serialization are generated from the actual types, so generics/nullability are handled at compile time. ## Polymorphism caveat For sealed hierarchies the plugin can wire a sealed serializer; for open polymorphism you register subtypes in a `SerializersModule`. Still no reflection — subtype mapping is explicit. ## Resolving the serializer `Json.encodeToString(value)` uses the `serializer<T>()` / `T.serializer()` resolved at compile time via a `reified` type parameter, so even the lookup avoids reflection.
- How does encodeToString find the right serializer without reflection?Via the reified type parameter: `inline fun <reified T> Json.encodeToString(value: T)` calls `serializer<T>()`, which the compiler resolves to the generated `T.serializer()` at the call site — a compile-time lookup, no runtime reflection.
- Does the SerialDescriptor get rebuilt on every serialize call?No. The descriptor is built once (lazily/statically in the generated serializer) and reused, so per-call work is just driving the encoder over the elements.
saying these in an interview costs you the question
- Saying kotlinx.serialization uses reflection at runtime like Gson
- Claiming the plugin generates code at runtime rather than compile time
- Not knowing what a SerialDescriptor is
- Thinking it only works on the JVM
- Believing you need ProGuard keep rules for the serialized classes