skip to content

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%

answer

  1. nested -> delegate to its serializer, don't inline fields
  2. encodeSerializableElement (non-null) vs encodeNullableSerializableElement
  3. decodeNullableSerializableElement for T?
  4. serializer.nullable gives KSerializer<T?>
  5. mark descriptor element nullable: element<String?>(...)

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.

solid answer

~30 s

Never re-encode a nested serializable type field-by-field; delegate to its serializer. At top level use `encoder.encodeSerializableValue(serializer, value)` / `decoder.decodeSerializableValue(serializer)`. Inside a structure use the element variants: `encodeSerializableElement(descriptor, index, serializer, value)` and `decodeSerializableElement(descriptor, index, serializer)`. For nullable fields use the nullable variants: `encodeNullableSerializableElement(descriptor, index, serializer, value)` and `decodeNullableSerializableElement(...)`, or obtain a nullable serializer via `serializer.nullable`. The descriptor element must be marked nullable (e.g. `element<String?>("name")`) so optionality/null is represented. Using the non-null element method with a null value, or the non-null serializer for a `T?`, throws or drops nulls; the nullable variants encode an explicit null and tolerate absence on decode.

code

kotlin · 6 lines
kotlin
encoder.encodeStructure(descriptor) {
    encodeSerializableElement(descriptor, 0, Address.serializer(), v.address)
    encodeNullableSerializableElement(
        descriptor, 1, String.serializer(), v.middleName,   // may be null
    )
}

go deeper

for a junior

Knows nullable fields need special handling and nested objects use their own serializer.

for a middle

Correctly uses encodeNullableSerializableElement/.nullable and marks the descriptor element nullable.

for a senior

Delegates nested serializers cleanly, reasons about descriptor optionality flags and format behavior for missing values.

for a principal

Designs serializers so nullability/optionality match schema-evolution rules and defaults across formats.

## Delegate nested serializable values If a field is itself a serializable type, don't decompose it manually — pass its serializer: ```kotlin encodeSerializableElement(descriptor, index, Address.serializer(), value.address) ``` This keeps the nested type's contract intact and works across formats. The decode side mirrors it with `decodeSerializableElement(descriptor, index, Address.serializer())`. ## Nullable fields need the nullable API Kotlin's nullability is part of the type. The serializer API has paired methods: - Non-null: `encodeSerializableElement` / `decodeSerializableElement`. - Nullable: `encodeNullableSerializableElement` / `decodeNullableSerializableElement`. The nullable variants write an explicit null (e.g. JSON `null`) and, on decode, return null when the value is absent/null. You can also wrap any serializer with the `.nullable` extension to get a `KSerializer<T?>`. ```kotlin override val descriptor = buildClassSerialDescriptor("User") { element<String>("id") element<String?>("nickname") // marked nullable } override fun serialize(encoder: Encoder, value: User) = encoder.encodeStructure(descriptor) { encodeStringElement(descriptor, 0, value.id) encodeNullableSerializableElement( descriptor, 1, String.serializer(), value.nickname, ) } ``` ## Why the descriptor matters Declare the element as nullable (`element<String?>(...)`) so the descriptor's `isNullable`/optional flags are correct. Formats use that to allow a missing/null value; a non-nullable element receiving null can throw. ## Primitive nullable shortcut For a top-level nullable scalar you can also expose `descriptor` via `.nullable` on a primitive serializer rather than the element API. ## Common mistakes - Calling `encodeStringElement` for a `String?` and crashing on null. - Forgetting to mark the descriptor element nullable, so decode of an absent value fails. - Re-encoding a nested object's fields inline instead of delegating to its serializer (breaks reuse and the nested type's own custom logic).

  • What does the .nullable extension do?
    It wraps a KSerializer<T> into a KSerializer<T?> that encodes/decodes explicit null, so you can reuse a non-null serializer for a nullable field.
  • Why delegate to a nested type's serializer instead of encoding its fields directly?
    Delegation preserves the nested type's own (possibly custom) contract, keeps the code DRY, and stays format-agnostic; inlining duplicates and can desync from its real descriptor.

saying these in an interview costs you the question

  • Using encodeStringElement for a nullable String and crashing on null
  • Not marking the descriptor element nullable
  • Inlining a nested object's fields instead of delegating to its serializer
  • Thinking null is automatically handled by the non-null element methods
  • Confusing element methods (inside a structure) with top-level value methods

context