Explain the surrogate (delegating) serializer pattern. How do you implement a KSerializer for an unannotated class by delegating to another @Serializable type?
answer
- private @Serializable surrogate shaped like the wire format
- descriptor = Surrogate.serializer().descriptor
- encodeSerializableValue / decodeSerializableValue
- map real <-> surrogate, validate in init
- avoids manual beginStructure/endStructure dance
basics
~20 sMake a small @Serializable helper class that has the exact fields you want on the wire. Your custom serializer borrows that helper's generated serializer, converting your real type to and from the helper. The helper does all the encoding.
solid answer
~30 sThe surrogate pattern handles a structured type you can't annotate (e.g. a library class) by introducing a private @Serializable 'surrogate' data class shaped like the desired wire format. Your KSerializer reuses the surrogate's auto-generated serializer: set `descriptor = Surrogate.serializer().descriptor`, then in `serialize` build a `Surrogate` from the real value and call `encoder.encodeSerializableValue(Surrogate.serializer(), surrogate)`; in `deserialize` call `decoder.decodeSerializableValue(Surrogate.serializer())` and map back. This avoids hand-writing the begin/encode-element/end structure dance — the compiler-generated surrogate serializer does it. It's the cleanest way to control structured output, validate invariants in the mapping, or rename/reshape fields without touching the original class.
code
kotlin · 12 lines@Serializable private class PointSurrogate(val x: Double, val y: Double)
object PointSerializer : KSerializer<Point> {
override val descriptor = PointSurrogate.serializer().descriptor
override fun serialize(e: Encoder, v: Point) =
e.encodeSerializableValue(PointSurrogate.serializer(),
PointSurrogate(v.x, v.y))
override fun deserialize(d: Decoder): Point {
val s = d.decodeSerializableValue(PointSurrogate.serializer())
return Point(s.x, s.y)
}
}go deeper
Recognizes that a helper @Serializable class can carry the wire shape.
Implements the full pattern: delegate descriptor, encode/decodeSerializableValue, map both directions.
Uses the surrogate as a validation and reshaping hub, keeps it private, sets @SerialName deliberately.
Chooses surrogate vs hand-rolled vs JsonTransformingSerializer based on format coverage, evolution and API stability.
## The problem For a *structured* type (multiple fields) you can't annotate, hand-writing `serialize` with `beginStructure`/`encodeXxxElement`/`endStructure` is verbose and error-prone. The **surrogate (delegating) pattern** offloads that work. ## The recipe 1. Define a private `@Serializable` **surrogate** data class with exactly the fields/names you want on the wire. 2. In your `KSerializer`, delegate the descriptor and the encode/decode to the surrogate's generated serializer. 3. Map your real type <-> surrogate in `serialize`/`deserialize`. ```kotlin class Color(val rgb: Int) @Serializable @SerialName("Color") private class ColorSurrogate(val r: Int, val g: Int, val b: Int) { init { require(r in 0..255 && g in 0..255 && b in 0..255) } } object ColorSerializer : KSerializer<Color> { override val descriptor: SerialDescriptor = ColorSurrogate.serializer().descriptor override fun serialize(encoder: Encoder, value: Color) { val s = ColorSurrogate( (value.rgb shr 16) and 0xff, (value.rgb shr 8) and 0xff, value.rgb and 0xff, ) encoder.encodeSerializableValue(ColorSurrogate.serializer(), s) } override fun deserialize(decoder: Decoder): Color { val s = decoder.decodeSerializableValue(ColorSurrogate.serializer()) return Color((s.r shl 16) or (s.g shl 8) or s.b) } } ``` ## Why it's good - **No manual structure dance.** `encodeSerializableValue`/`decodeSerializableValue` reuse the generated serializer, so element indices, optionals and nesting are handled for you. - **Validation hub.** Put `require(...)` in the surrogate's `init` or the mapping to enforce invariants on decode. - **Reshape freely.** The surrogate can rename fields, flatten, or add/drop fields the real class doesn't expose. - **Format-agnostic.** Works across JSON/ProtoBuf/CBOR because you delegate to a real serializer, not to JSON. ## Watch-outs - The surrogate's `@SerialName` becomes the visible type name; set it deliberately, especially under polymorphism. - Keep the surrogate `private` so it doesn't leak into your API. - This is distinct from a *primitive* delegate: surrogate = structured; for a single scalar you'd just delegate to `Int.serializer()` etc.
- Where do you put validation in the surrogate pattern?In the surrogate's init block or in the surrogate->real mapping, so invalid wire data is rejected on decode with a clear exception.
- Why delegate the descriptor instead of building your own?The generated surrogate descriptor already encodes the exact element names, kinds, indices and optionality you want; reusing it keeps descriptor and behavior perfectly in sync.
The surrogate is a stunt double: it does the dangerous structured-encoding scene so your real type never has to.
saying these in an interview costs you the question
- Hand-writing beginStructure/encodeIntElement when a surrogate would do
- Making the surrogate public and leaking it into the API
- Building a separate descriptor that drifts from the surrogate's actual fields
- Forgetting to map all fields back in deserialize
- Confusing surrogate (structured) with a primitive scalar delegate