skip to content

Inside a custom KSerializer, what is the contract between serialize/deserialize and the Encoder/Decoder, and how does the descriptor coordinate the two?

level: seniorimportance: should knowfreq 35%

answer

  1. Encoder/Decoder are abstract; serializer issues commands
  2. beginStructure(descriptor) -> CompositeEncoder/Decoder
  3. encodeXxxElement(descriptor, index, value) keyed by index
  4. decodeElementIndex loop until DECODE_DONE
  5. Descriptor = shared schema by element index

basics

~20 s

serialize writes data by calling methods on the Encoder; deserialize reads it back by calling methods on the Decoder. The descriptor describes the structure so both sides agree on names, order, and types of fields.

solid answer

~40 s

The serializer never sees the concrete format — it issues commands to the abstract `Encoder`/`Decoder`. For primitives it calls `encodeInt`/`decodeInt`, etc. For composite types it opens a scope: `encoder.beginStructure(descriptor)` returns a `CompositeEncoder` on which you call `encodeIntElement(descriptor, index, value)` for each field, then `endStructure(descriptor)`. Decoding mirrors this: `decoder.beginStructure(descriptor)` gives a `CompositeDecoder`; you loop calling `decodeElementIndex(descriptor)` until it returns `CompositeDecoder.DECODE_DONE`, dispatching on the returned index to `decodeIntElement(descriptor, index)`. The `descriptor` is the shared contract: its element indices, names, and kinds are how the encoder/decoder and the format line up fields. The recommended high-level helpers are `encodeStructure(descriptor) { ... }` and `decodeStructure(descriptor) { ... }`, which manage begin/end for you. decodeElementIndex also enables formats to deliver fields out of order (e.g. JSON).

code

kotlin · 11 lines
kotlin
override fun deserialize(decoder: Decoder): Point =
    decoder.decodeStructure(descriptor) {
        var x = 0; var y = 0
        while (true) when (val i = decodeElementIndex(descriptor)) {
            0 -> x = decodeIntElement(descriptor, 0)
            1 -> y = decodeIntElement(descriptor, 1)
            CompositeDecoder.DECODE_DONE -> return@decodeStructure Point(x, y)
            else -> error("Unexpected index $i")
        }
        @Suppress("UNREACHABLE_CODE") Point(x, y)
    }

go deeper

for a junior

Understands serialize writes and deserialize reads, but likely not the composite/index protocol.

for a middle

Can write a primitive serializer and recognizes beginStructure/encodeElement exist.

for a senior

Implements the full composite encode/decode protocol with the decodeElementIndex loop and explains how the descriptor coordinates by index.

for a principal

Reasons about format-order independence, decodeSequentially optimization, nested serializable delegation, and contract robustness across formats.

## The actors - **`Encoder`** — abstract sink the serializer writes to. Primitive methods (`encodeBoolean`, `encodeInt`, `encodeString`, `encodeNull`, `encodeSerializableValue`) plus `beginStructure(descriptor): CompositeEncoder` for composites. - **`Decoder`** — abstract source the deserializer reads from. Mirror methods (`decodeInt`, `decodeString`, …) plus `beginStructure(descriptor): CompositeDecoder`. - **`SerialDescriptor`** — the shared schema both sides reference by **element index**. ## Primitive flow ```kotlin override fun serialize(encoder: Encoder, value: Int) = encoder.encodeInt(value) override fun deserialize(decoder: Decoder): Int = decoder.decodeInt() ``` ## Composite flow (the important contract) Writing a multi-field type: ```kotlin override fun serialize(encoder: Encoder, value: Point) { encoder.encodeStructure(descriptor) { encodeIntElement(descriptor, 0, value.x) encodeIntElement(descriptor, 1, value.y) } } ``` `encodeStructure` calls `beginStructure(descriptor)`, runs the block on the returned `CompositeEncoder`, then `endStructure(descriptor)`. Each `encodeXxxElement` takes the **descriptor and the element index** so the format knows which named field it is. Reading mirrors it but must tolerate **any field order** and **missing optional fields**: ```kotlin override fun deserialize(decoder: Decoder): Point { return decoder.decodeStructure(descriptor) { var x = 0; var y = 0 while (true) { when (val index = decodeElementIndex(descriptor)) { 0 -> x = decodeIntElement(descriptor, 0) 1 -> y = decodeIntElement(descriptor, 1) CompositeDecoder.DECODE_DONE -> break else -> error("Unexpected index $index") } } Point(x, y) } } ``` `decodeElementIndex(descriptor)` returns the **index of the next element present** in the stream, or `DECODE_DONE` when finished. This is why JSON can present keys in any order: the decoder maps the encountered name to the descriptor index. (For strictly sequential formats, you may call `decodeSequentially()` and read in order as an optimization.) ## How the descriptor coordinates - It defines `elementsCount`, `getElementName(i)`, `getElementDescriptor(i)`, `isElementOptional(i)`, and `getElementIndex(name)`. - `encodeXxxElement`/`decodeXxxElement` are keyed by the **same indices** the descriptor exposes, so writer and reader agree without sharing concrete types. - Formats use it to translate between index and on-the-wire identity (JSON key, protobuf field number). ## Nested serializable values Use `encodeSerializableElement(descriptor, index, childSerializer, value)` and `decodeSerializableElement(...)` to delegate to another `KSerializer` for non-primitive fields. ## Pitfalls - Not handling `DECODE_DONE` -> infinite loop. - Assuming field order -> breaks for out-of-order JSON unless you opt into `decodeSequentially`. - Mismatched indices between encode and the descriptor -> wrong field names. - Forgetting `endStructure` (avoided by using `encode/decodeStructure` helpers).

  • Why must deserialize loop on decodeElementIndex instead of reading fields in fixed order?
    Formats like JSON may deliver keys in any order and may omit optional fields; the index loop lets the decoder report which element is next, so you handle any order and detect completion via DECODE_DONE.
  • What does decodeSequentially() buy you?
    For formats that guarantee field order (e.g. ProtoBuf), it signals you can read elements in declaration order without the index loop, a performance optimization.
  • How do you serialize a field that is itself a @Serializable type?
    Use encodeSerializableElement(descriptor, index, ChildType.serializer(), value) and the matching decodeSerializableElement, delegating to the child serializer.

saying these in an interview costs you the question

  • Calling beginStructure but never endStructure (or skipping the structure helpers)
  • Reading composite fields in fixed order assuming JSON ordering
  • Forgetting to break on CompositeDecoder.DECODE_DONE
  • Hand-rolling format syntax instead of using Encoder/Decoder methods
  • Using mismatched element indices vs the descriptor

context