skip to content

Walk through the three synthetic artifacts the kotlinx.serialization plugin generates for a @Serializable class and the role of each.

level: middleimportance: must knowfreq 55%

answer

  1. $serializer object = the engine
  2. SerialDescriptor = the shape/metadata
  3. serialize/deserialize = the read+write loop
  4. decodeElementIndex loop + DECODE_DONE
  5. Bitmask tracks seen fields for defaults

basics

~20 s

For each @Serializable class the plugin adds a hidden serializer object, a descriptor that lists the fields, and the functions that turn the object into data and back. Together they replace what reflection would do.

solid answer

~30 s

The plugin generates: (1) a nested synthetic object `$serializer` implementing `KSerializer<T>`; (2) its `SerialDescriptor`, built via `buildClassSerialDescriptor`/`PluginGeneratedSerialDescriptor`, listing each element's serial name, index, kind, optionality and nullability; (3) the `serialize(encoder, value)` and `deserialize(decoder)` overrides. `serialize` calls `encoder.beginStructure(descriptor)` then `encodeXxxElement(descriptor, index, value)` per property and `endStructure`. `deserialize` calls `decoder.beginStructure`, loops on `decodeElementIndex` until `CompositeDecoder.DECODE_DONE`, decodes each element, then constructs the object — tracking which optional fields were seen with a bitmask so it can apply default values. The plugin also synthesizes the companion `serializer()` accessor. Everything is keyed by **index**, which is why it needs no name-based reflection.

code

kotlin · 10 lines
kotlin
@Serializable
data class Point(val x: Int, val y: Int = 0)

fun main() {
    val d = Point.serializer().descriptor
    println(d.serialName)            // Point
    println(d.elementsCount)         // 2
    println(d.getElementName(1))     // y
    println(d.isElementOptional(1))  // true (has default)
}

go deeper

for a junior

Can name the serializer, descriptor, and serialize/deserialize even if fuzzy on the loop details.

for a middle

Explains the index-based encode/decode loop and that the descriptor carries field metadata.

for a senior

Describes the seen-field bitmask, synthetic constructor marker, decodeSequentially fast path, and how one $serializer serves many formats.

for a principal

Reasons about IR-level synthesis, descriptor caching/identity, and the contract that keeps formats decoupled from generated code.

## The three artifacts For `@Serializable class T`, the compiler plugin emits, at the IR/bytecode level: ### 1. `T.$serializer : KSerializer<T>` A **nested synthetic object** (singleton). `KSerializer<T>` combines `SerializationStrategy<T>` (write) and `DeserializationStrategy<T>` (read) plus a `descriptor`. This object is the engine the format (JSON/ProtoBuf/CBOR) calls. ### 2. `descriptor: SerialDescriptor` Describes the **shape** without the data: the class's `serialName`, the number of elements, and per element the serial name, **index**, `SerialKind`, whether it's `isOptional` (has a default), and whether it's nullable. The plugin uses an internal `PluginGeneratedSerialDescriptor`; you can build an equivalent by hand with `buildClassSerialDescriptor("T") { element<Int>("id") }`. Formats walk the descriptor to decide structure (object vs list), keys, and required-ness. ### 3. `serialize` and `deserialize` ```kotlin // Simplified shape of generated code override fun serialize(encoder: Encoder, value: T) { val out = encoder.beginStructure(descriptor) out.encodeIntElement(descriptor, 0, value.id) out.encodeStringElement(descriptor, 1, value.name) out.endStructure(descriptor) } override fun deserialize(decoder: Decoder): T { val inp = decoder.beginStructure(descriptor) var id = 0; var name = "" var bitMask = 0 // tracks which fields were read (for defaults) while (true) { when (val i = inp.decodeElementIndex(descriptor)) { 0 -> { id = inp.decodeIntElement(descriptor, 0); bitMask = bitMask or 1 } 1 -> { name = inp.decodeStringElement(descriptor, 1); bitMask = bitMask or 2 } CompositeDecoder.DECODE_DONE -> break else -> throw SerializationException("Unexpected index $i") } } inp.endStructure(descriptor) return T(id, name) } ``` ## Key mechanics - **Index-based:** every call passes a numeric `index`, not a string — no map/reflection lookup. - **Sequential fast path:** for trivially ordered input the runtime may use `decodeSequentially()` and skip the index loop. - **Bitmask for defaults:** the generated `deserialize` (and a synthetic constructor with a trailing `Int` seen-mask + `SerializationConstructorMarker`) records which optional fields appeared so defaults fill the rest. This is also why missing **non-optional** fields raise `MissingFieldException`. - **Companion accessor:** the plugin adds `companion object { fun serializer(): KSerializer<T> = $serializer }` (creating a companion if none exists). For generic types it generates `serializer(vararg typeParams: KSerializer<*>)`. ## Why three pieces, not one The split lets formats stay generic: the **descriptor** answers structural questions, while **serialize/deserialize** drive the actual byte/char stream through the abstract `Encoder`/`Decoder`. The same `$serializer` therefore works for JSON, ProtoBuf, CBOR, etc., with no per-format code.

  • Why does the generated deserialize use a bitmask?
    To record which fields actually appeared in the input so defaults are applied to absent optional fields and a MissingFieldException is thrown for absent required ones.
  • What is decodeSequentially() for?
    A fast path: when the decoder guarantees elements arrive in descriptor order with none missing, the generated code reads them directly without the index loop.

saying these in an interview costs you the question

  • Saying deserialize reads fields by name lookup
  • Not knowing the descriptor is separate from serialize/deserialize
  • Claiming defaults work without any seen-field tracking
  • Thinking the serializer is regenerated on every call instead of being a singleton object
  • Confusing SerialDescriptor with the encoded output itself

context