skip to content

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.

level: seniorimportance: should knowfreq 40%

answer

  1. Codegen = compile time, all platforms, no reflection
  2. serializer(KType)/serializer(Class) = JVM reflective fallback
  3. Erased Any -> reflective lookup at runtime
  4. Codegen fails at compile time; reflective fails at runtime
  5. Reflective path needs R8 keep rules

basics

~20 s

The 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 s

The 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
kotlin
@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

for a junior

Knows the normal path is compile-time generated and fast; may not know a reflective fallback exists.

for a middle

Recognizes that serializing an Any/erased value can fall back to runtime lookup and that the default path uses codegen.

for a senior

Articulates both paths, their failure modes, platform limits, and gives correct guidance plus R8 implications.

for a principal

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

context