What does the @Serializable annotation actually do at compile time, and why doesn't kotlinx.serialization need runtime reflection like many Java JSON libraries?
answer
- Compiler plugin, not runtime reflection
- Generates $serializer + SerialDescriptor + serialize/deserialize
- Encodes by index, not by reflected name
- Enables Native/JS + R8-safe
- Apply Gradle plugin kotlin.plugin.serialization
basics
~10 sAdding @Serializable tells a compiler plugin to generate the code that reads and writes the class. Because that code is built when you compile, the library doesn't have to inspect the class at runtime.
solid answer
~30 s@Serializable is a marker processed by the kotlinx.serialization compiler plugin. During compilation the plugin generates, for each annotated class, a nested synthetic object named $serializer (a KSerializer), a SerialDescriptor describing the fields, and serialize/deserialize functions that read/write each property by index. So the serialization logic is hard-coded at compile time rather than discovered via Java/Kotlin reflection at runtime. This makes it fast, ProGuard/R8-friendly, and Kotlin/Native and Kotlin/JS compatible, where full reflection isn't available. You retrieve the generated serializer with the synthesized companion method Foo.serializer() or the reified serializer<Foo>().
code
kotlin · 8 lines@Serializable
data class User(val id: Int, val name: String)
fun main() {
val text = Json.encodeToString(User(1, "Ann")) // {"id":1,"name":"Ann"}
val user = Json.decodeFromString<User>(text)
println(User.serializer().descriptor.serialName) // User
}go deeper
Knows @Serializable lets you call Json.encodeToString and that codegen happens at compile time, not via reflection.
Names the three generated artifacts ($serializer, SerialDescriptor, serialize/deserialize) and that encoding is index-based.
Explains the multiplatform/R8/perf motivation and how serializer() vs serializer<T>() resolve to the generated object.
Can contrast compiler-plugin codegen with KSP/reflection approaches and reason about build-time guarantees, shrinker rules, and IR-level synthesis tradeoffs.
## The problem reflection solves (and its cost) Many JVM JSON libraries (Jackson, Gson) inspect a class **at runtime** using **reflection** — APIs like `Class.getDeclaredFields()` — to discover property names and types, then read/write them. Reflection is flexible but slow to warm up, hard for ProGuard/R8 to shrink (fields can't be safely removed/renamed), and unavailable on **Kotlin/Native** and limited on **Kotlin/JS**. ## What @Serializable triggers kotlinx.serialization moves this work to **compile time** via a **compiler plugin** (you apply the Gradle plugin `org.jetbrains.kotlin.plugin.serialization`). When you annotate a class with `@Serializable`, the plugin synthesizes three things into the bytecode: - A nested object **`$serializer`** implementing `KSerializer<T>` — the actual read/write engine for that type. - A **`SerialDescriptor`** — metadata listing each property's serial name, index, type, and whether it's optional/nullable. Format engines (JSON, ProtoBuf) walk this to know the shape. - The **`serialize`** and **`deserialize`** functions inside `$serializer`, which encode/decode each property **by numeric index** (no name lookups via reflection). The plugin also adds a **`serializer()`** function (on the companion, or synthesized if absent) so you can obtain the serializer. ```kotlin @Serializable data class User(val id: Int, val name: String) // Conceptually generated by the plugin (simplified): // object User.$serializer : KSerializer<User> { // override val descriptor = buildClassSerialDescriptor("User") { // element<Int>("id"); element<String>("name") // } // override fun serialize(enc: Encoder, value: User) { // val c = enc.beginStructure(descriptor) // c.encodeIntElement(descriptor, 0, value.id) // c.encodeStringElement(descriptor, 1, value.name) // c.endStructure(descriptor) // } // override fun deserialize(dec: Decoder): User { /* loop over indices */ } // } val json = Json.encodeToString(User(1, "Ann")) // uses User.serializer() val back = Json.decodeFromString<User>(json) // reified -> serializer<User>() ``` ## Why this matters - **Performance:** no per-call reflection; encoding walks fixed indices. - **Multiplatform:** works on JVM, Native, and JS because nothing depends on JVM reflection. - **Shrinker-safe:** generated code references members directly, so R8/ProGuard can reason about them (you still keep `*$serializer` rules in some setups). - **Compile-time safety:** an unsupported field type fails the build, not at runtime. ## How you reach the generated code - `User.serializer()` — the synthesized companion function returns the `$serializer` instance. - `serializer<User>()` — a `reified` top-level helper that resolves to the same thing. - `Json.encodeToString(value)` / `decodeFromString<T>()` use these under the hood.
- Which Gradle plugin must be applied for @Serializable to work?org.jetbrains.kotlin.plugin.serialization (the compiler plugin); the runtime library kotlinx-serialization-json is a separate dependency.
- Does @Serializable produce any reflection at all?No reflection is needed for the generated path. The codegen produces direct calls; reflection only appears if you opt into runtime serializer lookup for non-annotated types.
Reflection is reading assembly instructions every time you build the chair; the plugin prints the finished assembly steps once at the factory.
saying these in an interview costs you the question
- Claiming kotlinx.serialization uses runtime reflection like Gson
- Thinking @Serializable alone works without the Gradle compiler plugin
- Saying it works only on the JVM
- Confusing the annotation with an annotation processor (KAPT/KSP) — it's a compiler plugin
- Believing you must write the serializer by hand