skip to content

How do you implement a custom KSerializer<T> for a value class that should serialize as a primitive (e.g. a Color stored as an Int hex code serialized as a String)? Walk through serialize, deserialize, and descriptor.

level: seniorimportance: should knowfreq 45%

answer

  1. PrimitiveSerialDescriptor(name, PrimitiveKind.X)
  2. serialize -> encoder.encodeString(...)
  3. deserialize -> decoder.decodeString()
  4. descriptor kind must match encode/decode primitive
  5. Wire via @Serializable(with=...) or SerializersModule contextual

basics

~20 s

Write a class implementing KSerializer<Color>. Give it a primitive descriptor (a String kind). In serialize, convert the Color to a String and call encodeString. In deserialize, call decodeString and parse it back into a Color.

solid answer

~40 s

Implement `KSerializer<Color>`. For a primitive representation, build the descriptor with `PrimitiveSerialDescriptor("Color", PrimitiveKind.STRING)` — the serial name and the kind must match what you actually encode. In `serialize(encoder, value)` you call the matching primitive method, `encoder.encodeString(value.toHex())`. In `deserialize(decoder)` you call `decoder.decodeString()` and parse it. Wire it up with `@Serializable(with = ColorSerializer::class)` on the type, or `@Serializable(with = ...)` at a property, or register it in a SerializersModule. The critical invariant: the descriptor's kind must agree with the encode/decode primitive used, otherwise some formats (notably JSON in strict mode) behave inconsistently. Do NOT build a primitive descriptor with element-based methods like beginStructure — that's only for structured serializers.

code

kotlin · 8 lines
kotlin
object ColorSerializer : KSerializer<Color> {
    override val descriptor =
        PrimitiveSerialDescriptor("com.acme.Color", PrimitiveKind.STRING)
    override fun serialize(encoder: Encoder, value: Color) =
        encoder.encodeString(value.toHex())
    override fun deserialize(decoder: Decoder): Color =
        Color.fromHex(decoder.decodeString())
}

go deeper

for a junior

Can describe the goal (turn Color into a String) but may not know PrimitiveSerialDescriptor or the kind/encode matching rule.

for a middle

Implements serialize/deserialize with encodeString/decodeString and a primitive descriptor correctly.

for a senior

Gets the descriptor-kind-must-match invariant, knows the wiring options, and contrasts the primitive vs structured (buildClassSerialDescriptor) paths.

for a principal

Discusses statelessness/thread-safety, contextual registration for decoupling, format portability of the chosen representation, and migration concerns.

## When you write a custom KSerializer You hand-write a `KSerializer` when the compiler plugin's default (member-by-member) shape isn't what you want — for example, representing a domain type as a single primitive on the wire. ## The three parts, for a primitive shape ### descriptor For a type encoded as a single primitive, use `PrimitiveSerialDescriptor(serialName, kind)`: ```kotlin override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("com.acme.Color", PrimitiveKind.STRING) ``` - The **serial name** should be unique (often the fully qualified name) — it identifies the type, e.g. in polymorphism and error messages. - The **kind** (`PrimitiveKind.STRING`, `.INT`, …) must match the encode/decode call you use. A primitive descriptor has zero elements; calling structured methods against it is an error. ### serialize Convert your value to the primitive and emit it with the matching `encode*`: ```kotlin override fun serialize(encoder: Encoder, value: Color) { encoder.encodeString(value.toHex()) // e.g. "#FF8800" } ``` ### deserialize Read the matching primitive and reconstruct: ```kotlin override fun deserialize(decoder: Decoder): Color { return Color.fromHex(decoder.decodeString()) } ``` ## Full example ```kotlin object ColorSerializer : KSerializer<Color> { override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("com.acme.Color", PrimitiveKind.STRING) override fun serialize(encoder: Encoder, value: Color) { encoder.encodeString(value.toHex()) } override fun deserialize(decoder: Decoder): Color { return Color.fromHex(decoder.decodeString()) } } @Serializable(with = ColorSerializer::class) @JvmInline value class Color(val rgb: Int) { fun toHex() = "#%06X".format(rgb and 0xFFFFFF) companion object { fun fromHex(s: String) = Color(s.removePrefix("#").toInt(16)) } } ``` ## Wiring options - `@Serializable(with = ColorSerializer::class)` on the class — applies everywhere. - `@Serializable(with = ColorSerializer::class)` on a single property — overrides for that field only. - Register in a `SerializersModule` (`contextual(ColorSerializer)`) and use `@Contextual` — decouples the type from a specific serializer. ## Pitfalls - **Descriptor/encode mismatch**: descriptor says STRING but you call `encodeInt` — inconsistent and can break format assumptions. - **Stateful object**: a serializer should be effectively stateless/thread-safe; `object` is the idiomatic shape. - **Nullability**: don't special-case null inside the serializer; use `ColorSerializer.nullable` or let the framework handle `Color?`. - For a **structured** (multi-field) custom serializer you'd instead use `buildClassSerialDescriptor` + `encodeStructure`/`decodeStructure` with `encodeElement`/`decodeElementIndex` — but that's the non-primitive path.

  • How would the implementation change if Color should serialize as a JSON object with r/g/b fields instead of a string?
    Use buildClassSerialDescriptor with three elements, then encodeStructure { encodeIntElement(...) } in serialize and decodeStructure { loop on decodeElementIndex } in deserialize — the structured path rather than the primitive one.
  • Why prefer an object over a class for the serializer?
    Serializers should be stateless and are looked up/used concurrently; a singleton object is cheap, thread-safe, and is what @Serializable(with=...) expects (a no-arg-constructible type).
  • What if the descriptor's kind doesn't match the encode call?
    It's a contract violation: the descriptor advertises the wire shape that formats rely on, so a STRING descriptor with encodeInt can produce inconsistent output or decoding errors.

saying these in an interview costs you the question

  • Using beginStructure/encodeStructure for a single-primitive representation
  • Hardcoding JSON quotes/braces instead of calling encoder methods
  • Descriptor kind not matching the encode/decode primitive
  • Making the serializer stateful or non-thread-safe
  • Manually handling null inside serialize instead of using .nullable

context