kotlinx.serialization can resolve serializers via compiler codegen or, on the JVM, via runtime reflection (e.g. Json.encodeToString(value) on an Any). Compare the two paths and their consequences.
answer
- Codegen = compile time, all platforms, no reflection
- serializer(KType)/serializer(Class) = JVM reflective fallback
- Erased Any -> reflective lookup at runtime
- Codegen fails at compile time; reflective fails at runtime
- Reflective path needs R8 keep rules
basics
~20 sThe normal path uses code the plugin built at compile time, which is fast and works everywhere. There's also a JVM-only fallback that finds the serializer with reflection at runtime, which is slower and platform-limited but more dynamic.
solid answer
~40 sThe default, recommended path is **compiler codegen**: `Foo.serializer()` / `serializer<Foo>()` return the plugin-generated `$serializer`, resolved at compile time, no reflection, multiplatform, and R8-safe. The JVM-only fallback is **runtime reflective resolution** via `serializer(KType)` / `serializer(Class)` from `kotlinx-serialization-core`'s JVM artifact: given a `KClass`, it reflectively looks up the companion's `$serializer` (or builds one for built-ins/generics). You hit this when you serialize a value whose static type is `Any`/erased, or call `Json.encodeToString(value)` without a reified type. Consequences: the reflective path costs a runtime lookup (cached), is **JVM-only**, can throw `SerializationException` at runtime if no serializer exists, and needs ProGuard/R8 keep rules. The codegen path fails at **compile time** instead and runs with zero lookup. Prefer reified/explicit serializers; use reflective lookup only for genuinely dynamic types.
code
kotlin · 9 lines@Serializable data class User(val id: Int)
fun encodeKnown(u: User) = Json.encodeToString(u) // codegen, reified
fun encodeDynamic(any: Any): String { // JVM reflective fallback
val ser = serializer(any::class.starProjectedType)
@Suppress("UNCHECKED_CAST")
return Json.encodeToString(ser as KSerializer<Any>, any)
}go deeper
Knows the normal path is compile-time generated and fast; may not know a reflective fallback exists.
Recognizes that serializing an Any/erased value can fall back to runtime lookup and that the default path uses codegen.
Articulates both paths, their failure modes, platform limits, and gives correct guidance plus R8 implications.
Weighs API design tradeoffs, caching of reflective lookups, generic type-argument assembly, and when to expose reflective resolution in a library boundary.
## Two resolution paths ### A. Compile-time codegen (the default) When the **static type is known**, the compiler plugin wires the serializer directly: - `Json.encodeToString(user)` where `user: User` → the reified overload calls `serializer<User>()` → `User.$serializer`. - `User.serializer()` → returns the generated object. No reflection, resolved during compilation. Works on **JVM, Native, JS, Wasm**. Friendly to **R8/ProGuard** because references are concrete. Missing/illegal serializers are **compile errors**. ### B. Runtime reflective resolution (JVM fallback) Provided by the JVM artifact of `kotlinx-serialization-core`: ```kotlin val s: KSerializer<Any?> = serializer(typeOf<List<User>>()) // from KType val s2 = User::class.serializer() // from KClass (JVM) ``` Given a `KClass`/`KType`, the library uses **reflection** to find the nested `Companion.serializer()` / `$serializer`, assembling type-argument serializers for generics. You implicitly use it when the static type is erased: ```kotlin val anything: Any = User(1, "Ann") Json.encodeToString(anything) // no compile-time type -> reflective lookup of anything::class ``` ## Consequences compared | Aspect | Codegen path | Reflective path | |---|---|---| | When resolved | Compile time | Runtime | | Reflection used | No | Yes (JVM only) | | Platforms | All Kotlin targets | JVM only | | Failure mode | Compile error | Runtime `SerializationException` | | Performance | No lookup | One cached reflective lookup | | Shrinker | Concrete refs | Needs keep rules for `*$serializer` | ## Practical guidance - **Prefer** the reified/explicit form: `encodeToString<T>(value)` or pass an explicit `KSerializer`. - Reach for reflective `serializer(KType)` only for **genuinely dynamic** types (plugin systems, type known only at runtime), and only on the JVM. - Keep **R8/ProGuard** rules (e.g. keep `**$serializer`) when relying on reflective lookup; the codegen path generally needs less. - A common pitfall: `Json.encodeToString(listOf(user))` where the variable is typed `List<Any>` serializes via the erased element type and can lose subtype info or throw — pass the precise type/serializer. ## Why both exist Codegen gives speed, safety, and multiplatform reach; the reflective bridge preserves the dynamic ergonomics JVM developers expect when types are only known at runtime. The library defaults to codegen and treats reflection as an explicit, JVM-scoped escape hatch.
- Why might Json.encodeToString(value) compile but throw at runtime?If value's static type is Any/erased, the library falls back to reflective lookup of value::class; if that class has no generated serializer, it throws SerializationException at runtime instead of failing the build.
- Is the reflective path available on Kotlin/Native?No. Runtime serializer-by-KClass reflection is JVM-only; Native/JS rely on compile-time codegen and explicit serializers.
saying these in an interview costs you the question
- Claiming kotlinx.serialization never uses reflection at all
- Using serializer(KType) reflection on hot paths unnecessarily
- Not realizing erased Any triggers a runtime lookup
- Forgetting ProGuard/R8 keep rules for reflective resolution
- Assuming the reflective API works on all platforms