skip to content

Custom Serializers

You write a KSerializer by hand when the default shape is wrong, wiring it in with @Serializable(with = ...) or a file-level declaration. The surrogate pattern — serialize via an intermediate data class — is the trick that saves most of the work.

part ofKotlinoverview, primer and where to startread it →
on this pageshow

questions

6

In kotlinx.serialization, what is a custom KSerializer and when would you write one by hand instead of relying on the @Serializable-generated serializer?

level: juniorimportance: must knowfreq 60%

answer

  1. descriptor + serialize + deserialize = the three members
  2. @Serializable generates one; hand-write when default is wrong
  3. third-party type / different wire shape / validation
  4. @Serializable(with=...) or @file:UseSerializers
  5. format-agnostic: works for JSON, ProtoBuf, CBOR

basics

~20 s

It is a small class that tells the library exactly how to turn one type into data and back. You write one when the default behavior is wrong, for types you cannot annotate, or when the wire format must differ from your class.

solid answer

~40 s

A KSerializer<T> is an object implementing three things: a descriptor (the type's shape/name for the format), serialize(encoder, value), and deserialize(decoder). For a plain @Serializable class the compiler plugin generates one automatically. You hand-write one when: the class comes from a third-party library you cannot annotate; the JSON/binary shape must differ from the Kotlin class (e.g. a date stored as an epoch Long but modeled as Instant); or you need to validate/transform during (de)serialization. You attach it with @Serializable(with = MySerializer::class) on the property or class, or register it at file level via @file:UseSerializers(MySerializer::class). The serializer is format-agnostic: the same KSerializer works for JSON, ProtoBuf, CBOR.

code

kotlin · 8 lines
kotlin
object InstantSerializer : KSerializer<Instant> {
    override val descriptor =
        PrimitiveSerialDescriptor("Instant", PrimitiveKind.LONG)
    override fun serialize(encoder: Encoder, value: Instant) =
        encoder.encodeLong(value.epochSecond)
    override fun deserialize(decoder: Decoder): Instant =
        Instant.ofEpochSecond(decoder.decodeLong())
}

go deeper

for a junior

Knows a KSerializer has descriptor, serialize, deserialize and that @Serializable usually generates it.

for a middle

Can list concrete reasons to hand-write one (third-party type, different shape, validation) and wire it via @Serializable(with=...).

for a senior

Stresses format-agnosticism and decoupling the wire contract from the model; reaches for @file:UseSerializers at scale.

for a principal

Frames custom serializers as a stable API boundary and weighs them against schema-evolution and polymorphism strategies.

## What a KSerializer is kotlinx.serialization splits work into two halves: a **format** (Json, ProtoBuf, Cbor) that knows how to write bytes, and a **serializer** that knows the *structure* of a specific type. A `KSerializer<T>` is the per-type strategy. The interface has three members: - `descriptor: SerialDescriptor` — metadata describing the type's shape (kind, name, element names). Formats read this to know how many fields exist and what to call them. - `serialize(encoder: Encoder, value: T)` — writes `value` into the supplied `Encoder`. - `deserialize(decoder: Decoder): T` — reads a `T` back from the `Decoder`. ## Why the compiler usually does it for you When you put `@Serializable` on a class, the **compiler plugin** generates a nested serializer (`MyClass.serializer()`) plus the descriptor automatically. You almost never see it. ## When to hand-write one You write a custom `KSerializer` when the generated one is unavailable or wrong: - **Third-party type** you can't annotate (e.g. `java.time.Instant`, a class from a dependency). - **Format differs from the model** — your class is `Color(r,g,b)` but the JSON must be the string `"#ff0000"`. - **Validation/normalization** — reject out-of-range values, trim strings, enforce invariants while decoding. - **Stable wire contract** — you want the serialized form decoupled from internal refactors. ## Wiring it in ```kotlin // Per-use, on a property or the class itself: @Serializable data class Event( @Serializable(with = InstantSerializer::class) val at: Instant, ) // File-wide, so every Instant in the file uses it: @file:UseSerializers(InstantSerializer::class) ``` A hand-written serializer is **format-agnostic** — the same `InstantSerializer` is used whether the format is JSON, ProtoBuf or CBOR, because it talks to the abstract `Encoder`/`Decoder`, not to JSON directly.

  • Does a custom KSerializer only work for JSON?
    No. It talks to the abstract Encoder/Decoder, so the same serializer works for any format — JSON, ProtoBuf, CBOR, etc.
  • What's the difference between @Serializable(with=...) and @file:UseSerializers?
    @Serializable(with=...) applies to a single class or property; @file:UseSerializers registers serializers for all occurrences of those types within the file.

A KSerializer is a translator hired for one specific language pair; the format is the messenger who carries whatever the translator produces.

saying these in an interview costs you the question

  • Thinking a custom serializer is JSON-specific
  • Believing you must annotate every class with @Serializable to serialize it
  • Confusing the format (Json) with the serializer (per-type strategy)
  • Not knowing the three members descriptor/serialize/deserialize
  • Claiming reflection is used at runtime (it's compiler-generated)

context

open as a page

Write a custom KSerializer for a value type using PrimitiveSerialDescriptor. Why must the descriptor's serialName be unique, and what determines the PrimitiveKind?

level: middleimportance: must knowfreq 55%

basics

~20 s

Use PrimitiveSerialDescriptor with a unique name and the basic kind (string, long, etc.) your wire value has. The name must be unique so the format can tell types apart; the kind matches the encode/decode call you actually use.

open as a page

Explain the surrogate (delegating) serializer pattern. How do you implement a KSerializer for an unannotated class by delegating to another @Serializable type?

level: middleimportance: should knowfreq 45%

basics

~20 s

Make 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.

open as a page

Hand-write serialize/deserialize for a structured type using beginStructure/endStructure and decodeElementIndex. Why is the decodeElementIndex loop necessary and how do you handle CompositeDecoder.DECODE_DONE?

level: seniorimportance: should knowfreq 40%

basics

~20 s

You open a structure, write each field with its index, then close it. To read, you loop asking the decoder which field comes next until it says done. The loop is needed because formats may send fields in any order or skip optional ones.

open as a page

Compare the ways to wire a custom serializer: @Serializable(with=...), @file:UseSerializers, @Contextual + SerializersModule, and KSerializer<T> by ... delegation. When is each appropriate?

level: seniorimportance: should knowfreq 42%

basics

~10 s

Annotate one spot with @Serializable(with=...). Cover a whole file with @file:UseSerializers. Register globally (especially for third-party types) with @Contextual plus a SerializersModule. Use 'by' delegation to reuse another serializer's logic while tweaking the type.

open as a page

When hand-writing a serializer, how do you correctly handle nested serializable values and nullable fields? Contrast encodeSerializableValue with encodeNullableSerializableElement.

level: middleimportance: nice to knowfreq 30%

basics

~20 s

For a nested object, hand the encoder the nested type's own serializer instead of encoding fields yourself. For a field that may be null, use the nullable-element methods so null is written and read correctly instead of crashing.

open as a page