skip to content

Why does KSerializer expose a SerialDescriptor rather than relying on Kotlin reflection at runtime, and what does the descriptor's kind tell a format?

level: middleimportance: should knowfreq 30%

answer

  1. No runtime reflection -> compile-time plugin metadata
  2. Reasons: performance + multiplatform (JS/Native/Wasm)
  3. kind = SerialKind: Primitive/Structure(CLASS,LIST,MAP)/ENUM/Polymorphic
  4. Format branches on kind to choose {} [] scalar
  5. Trade-off: every type must be @Serializable

basics

~20 s

The descriptor is a lightweight, precomputed description of a type's structure, generated at compile time so the library doesn't need slow runtime reflection. Its kind tells the format whether the value is a primitive, an object, a list, a map, and so on.

solid answer

~40 s

kotlinx.serialization deliberately avoids runtime reflection for performance and multiplatform reach (it must work on Kotlin/JS and Kotlin/Native where JVM reflection isn't available). Instead, the compiler plugin emits a `SerialDescriptor` at compile time describing each type: its `serialName`, `kind`, element count, per-element names, descriptors, optionality, and annotations. The `kind` (a `SerialKind`) is the key dispatch signal for a format: `PrimitiveKind.{INT,STRING,...}` for scalars; `StructureKind.CLASS`/`OBJECT` for objects; `StructureKind.LIST`/`MAP` for collections; `SerialKind.ENUM`; and `PolymorphicKind.{SEALED,OPEN}` for polymorphism. A JSON encoder uses the kind to decide whether to emit `{}`, `[]`, a bare scalar, etc. This is why serialization is fast and works everywhere, but also why everything must be declared @Serializable up front — there's no runtime fallback that inspects arbitrary classes.

go deeper

for a junior

Knows the descriptor describes the type but may not know why reflection is avoided.

for a middle

Explains the compile-time/no-reflection design and lists the main kinds and how formats use them.

for a senior

Connects the design to multiplatform support and the @Serializable requirement, and can build descriptors via buildClassSerialDescriptor.

for a principal

Weighs the reflection-free trade-off (perf/multiplatform vs flexibility) against reflective libraries and discusses interop strategies for non-serializable third-party types.

## The design choice Many serialization libraries (e.g. Jackson, Gson) inspect classes via **runtime reflection**. kotlinx.serialization deliberately does **not** for two reasons: 1. **Performance** — runtime reflection is comparatively slow; precomputed metadata is fast. 2. **Multiplatform** — Kotlin targets JVM, JS, Native, and Wasm. JVM-style reflection isn't uniformly available, so the library relies on a compiler **plugin** that generates metadata at build time and works on every target. The artifact of that generation is the **`SerialDescriptor`** carried by each `KSerializer`. ## What a SerialDescriptor holds - `serialName: String` — the type's identity (used in polymorphism, errors). - `kind: SerialKind` — the structural category (see below). - `elementsCount: Int` and, per index: `getElementName(i)`, `getElementDescriptor(i)`, `isElementOptional(i)`, `getElementAnnotations(i)`, `isNullable`. - `getElementIndex(name): Int` — maps a wire name back to an index. ## The kinds `SerialKind` subtypes: - **`PrimitiveKind`**: `BOOLEAN, BYTE, SHORT, INT, LONG, FLOAT, DOUBLE, CHAR, STRING` — scalar values. - **`StructureKind`**: `CLASS` (regular class), `OBJECT` (singleton), `LIST` (sequence), `MAP` (key/value pairs). - **`SerialKind.ENUM`** and `SerialKind.CONTEXTUAL`. - **`PolymorphicKind`**: `SEALED`, `OPEN` — for type-tagged polymorphic data. ```kotlin val d = User.serializer().descriptor when (d.kind) { StructureKind.CLASS -> { /* emit object */ } StructureKind.LIST -> { /* emit array */ } is PrimitiveKind -> { /* emit scalar */ } else -> {} } ``` ## How a format uses kind A `Json` encoder branches on `kind`: `StructureKind.CLASS` -> `{ ... }`, `StructureKind.LIST` -> `[ ... ]`, `PrimitiveKind.STRING` -> a quoted scalar, `PolymorphicKind` -> a tagged wrapper with a discriminator (default `type`). The decoder uses element names/indices to route incoming data. ## Building descriptors yourself - Primitive: `PrimitiveSerialDescriptor(name, kind)`. - Class-like: `buildClassSerialDescriptor(name) { element<Int>("x"); element<String>("y") }`. - Delegating: reuse another serializer's descriptor (e.g. `SerialDescriptor(name, delegate.descriptor)`). ## Consequence / trade-off Because there's no runtime class inspection, **every** participating type must be `@Serializable` (or have a contextual/custom serializer). You can't "just serialize" an arbitrary third-party class without providing a serializer — the cost of the reflection-free, multiplatform design.

  • Why can't kotlinx.serialization just serialize any arbitrary class like Jackson?
    It has no runtime reflection fallback; the plugin must generate a descriptor and serializer at compile time, so the type must be @Serializable or have a custom/contextual serializer.
  • How does a JSON encoder know to write [] vs {}?
    It inspects descriptor.kind: StructureKind.LIST -> array, StructureKind.CLASS/OBJECT/MAP -> object-like, PrimitiveKind -> scalar.

The descriptor is like a building's blueprint filed at construction time: inspectors (formats) read the blueprint instead of tearing open walls (reflection) every visit.

saying these in an interview costs you the question

  • Claiming kotlinx.serialization uses runtime reflection like Gson/Jackson
  • Not knowing the multiplatform motivation
  • Thinking the descriptor is optional or runtime-built only
  • Unaware that kind drives format structure decisions

context