skip to content

How does kotlinx-serialization work in common code, and why is it suitable for multiplatform while reflection-based libraries are not?

level: middleimportance: must knowfreq 55%

answer

  1. @Serializable -> compiler plugin generates KSerializer
  2. no runtime reflection => works on Native/JS
  3. need a format module (Json)
  4. @SerialName / @Transient / encodeDefaults / ignoreUnknownKeys
  5. sealed = auto polymorphism; open needs SerializersModule

basics

~20 s

You mark a class with @Serializable and a compiler plugin generates the conversion code at build time. Because it doesn't use runtime reflection, it works on platforms like iOS and JS where reflection isn't available.

solid answer

~40 s

kotlinx-serialization is driven by a **compiler plugin** plus a runtime. Annotating a class `@Serializable` makes the plugin generate a `KSerializer<T>` at compile time, describing fields via a `SerialDescriptor` and encoding/decoding through `Encoder`/`Decoder`. You pick a format module — `Json` from kotlinx-serialization-json — and call `Json.encodeToString(value)` / `Json.decodeFromString<T>(text)`. Because serializers are generated statically, there is **no runtime reflection**, which is essential on Kotlin/Native and Kotlin/JS where JVM-style reflection is absent or limited. You enable it by applying the `kotlin("plugin.serialization")` Gradle plugin and adding the format dependency to commonMain. Customisation uses `@SerialName`, `@Transient`, `@Required`, polymorphism via sealed classes with a `SerializersModule`, and `Json { ignoreUnknownKeys = true; encodeDefaults = false }` configuration. All of this lives in common code and runs identically on every target.

code

kotlin · 11 lines
kotlin
import kotlinx.serialization.*
import kotlinx.serialization.json.Json

@Serializable sealed interface Shape
@Serializable @SerialName("circle") data class Circle(val r: Double) : Shape
@Serializable @SerialName("rect") data class Rect(val w: Double, val h: Double) : Shape

val json = Json { classDiscriminator = "type" }
val out = json.encodeToString<Shape>(Circle(2.0))
// {"type":"circle","r":2.0}
val shape: Shape = json.decodeFromString(out)

go deeper

for a junior

Knows @Serializable plus Json.encodeToString/decodeFromString and that no reflection is used.

for a middle

Explains the compiler plugin, format modules, and common knobs like ignoreUnknownKeys and @SerialName.

for a senior

Discusses SerialDescriptor/Encoder/Decoder, sealed vs open polymorphism, SerializersModule, and custom serializers.

for a principal

Weighs format choice, schema-evolution strategy, contextual serialization, and performance/binary-size trade-offs across targets.

## The big idea: compile-time, not reflection Most JVM serializers (Jackson, Gson) inspect classes at **runtime via reflection**. That doesn't translate to Kotlin/Native or Kotlin/JS, where full reflection is unavailable. kotlinx-serialization instead uses a **Kotlin compiler plugin**: at build time it generates the serialization logic, so it works on every KMP target. ## The pieces - **`@Serializable`** — annotation that tells the plugin to generate a `KSerializer<T>` for the class. - **`KSerializer<T>`** — the generated object with two halves: `serialize(encoder, value)` and `deserialize(decoder)`, plus a `descriptor`. - **`SerialDescriptor`** — a format-independent description of the type's shape (its element names, kinds, nullability). - **`Encoder` / `Decoder`** — abstractions a *format* implements; the serializer calls `encodeStringElement`, `decodeIntElement`, etc., and the format decides the bytes/text. - **Format module** — e.g. **`Json`** (kotlinx-serialization-json). Others exist (protobuf, cbor) as separate artifacts. You need at least one. ## Using it ```kotlin import kotlinx.serialization.* import kotlinx.serialization.json.Json @Serializable data class User( val id: Long, @SerialName("display_name") val name: String, val tags: List<String> = emptyList(), @Transient val cached: Boolean = false // skipped ) val json = Json { ignoreUnknownKeys = true; encodeDefaults = false } val text = json.encodeToString(User(1, "Ada")) val u: User = json.decodeFromString(text) ``` ## Gradle setup Apply the plugin and add the format: ```kotlin plugins { kotlin("plugin.serialization") version "2.0.20" } // commonMain: implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3") ``` ## Important behaviours / knobs - **`@SerialName`** renames a field on the wire; **`@Transient`** excludes it (the property must have a default). - **`encodeDefaults = false`** omits properties equal to their default; **`ignoreUnknownKeys = true`** tolerates extra JSON keys. - **Polymorphism**: a `sealed` hierarchy gets a generated discriminator; open/abstract polymorphism needs a `SerializersModule` registering subtypes, configured via `Json { serializersModule = ... }`. A `classDiscriminator` controls the type key. - **Default Json is strict**: unknown keys throw unless `ignoreUnknownKeys`; missing non-optional fields throw `MissingFieldException`. - **Contextual/custom**: `@Contextual` defers to a runtime-registered serializer; you can hand-write a `KSerializer` for exotic types. ## Why it fits multiplatform No runtime reflection means the same `@Serializable` class and `Json` instance work byte-for-byte the same on JVM, Native, JS and Wasm — all defined once in commonMain.

  • What happens by default if the JSON contains a key your class doesn't declare?
    The strict default Json throws. Set Json { ignoreUnknownKeys = true } to silently skip unknown keys — common when consuming evolving APIs.
  • How do you serialize an open/abstract polymorphic hierarchy that isn't sealed?
    Register each subtype in a SerializersModule via polymorphic { subclass(...) } and pass it as Json { serializersModule = ... }; sealed hierarchies are handled automatically.

Reflection libraries read the recipe while cooking; kotlinx-serialization prints the exact steps at build time so any kitchen, even one without a cookbook, can follow them.

saying these in an interview costs you the question

  • Saying it uses runtime reflection like Gson/Jackson
  • Forgetting to apply the plugin.serialization Gradle plugin
  • Claiming kotlinx-serialization-core alone can produce JSON
  • Not knowing default Json is strict about unknown/missing keys
  • Thinking @Transient works on a property without a default value

context