skip to content

When does the kotlinx.serialization plugin NOT generate a serializer for a @Serializable type, and what does it do instead?

level: middleimportance: should knowfreq 35%

answer

  1. with = MySerializer -> no body generated, only accessor
  2. Enums -> compact enum serializer (ENUM kind)
  3. object -> empty-structure object serializer
  4. Sealed -> polymorphic serializer + subtype delegation
  5. External types -> hand-written or contextual, no codegen

basics

~20 s

If you already supply your own serializer, or the type is something the library handles specially (like enums or objects), the plugin skips generating the usual code and uses the provided or built-in logic instead.

solid answer

~40 s

The plugin **skips full codegen** in several cases. With `@Serializable(with = MySerializer::class)` you tell it to use **your** `KSerializer`, so it generates only the `serializer()` accessor pointing at yours — no `serialize`/`deserialize` body. For **enums**, it emits a lightweight enum serializer rather than a per-property structure. For singleton **`object`** declarations it generates an object serializer with an empty structure. For types annotated `@Serializable` but delegating via `@Serializable @SerialName` to a typealias/`@Serializable(with=...)`, again your serializer wins. It also does **not** generate for `abstract`/`sealed` parents the same way it does concrete classes — sealed types get a polymorphic serializer keyed by subtype. And purely external types you don't own are handled with a hand-written serializer or contextual lookup, not codegen. The detail to know: `with =` replaces the generated body but still wires discoverability.

code

kotlin · 9 lines
kotlin
object Upper : KSerializer<String> {
    override val descriptor = PrimitiveSerialDescriptor("Upper", PrimitiveKind.STRING)
    override fun serialize(e: Encoder, v: String) = e.encodeString(v.uppercase())
    override fun deserialize(d: Decoder): String = d.decodeString()
}

@Serializable
data class Tag(@Serializable(with = Upper::class) val label: String)
// plugin generates Tag's body, but uses Upper for the label property

go deeper

for a junior

Knows you can supply a custom serializer with with = instead of the generated one.

for a middle

Lists the main skip cases (custom with, enum, object) and that the accessor is still generated.

for a senior

Covers sealed/polymorphic delegation, external/contextual types, and what discoverability the annotation preserves even when bodies aren't generated.

for a principal

Reasons about the annotation-as-discoverability contract, how it composes across nested types, and library API design for owned vs external types.

## Default: full structural codegen For an ordinary concrete `@Serializable class`, the plugin generates `$serializer` with a full per-property `serialize`/`deserialize` (as covered elsewhere). But several shapes opt out of that. ## Cases where structural codegen is skipped ### 1. Custom serializer via `with =` ```kotlin @Serializable(with = ColorAsHexSerializer::class) class Color(val rgb: Int) ``` The plugin does **not** synthesize `serialize`/`deserialize`. It wires `Color.serializer()` to return your `ColorAsHexSerializer`. Same for the file/type-level `@file:UseSerializers` and property-level `@Serializable(with=...)` — your code owns the logic; the plugin only ensures **discoverability**. ### 2. Enums ```kotlin @Serializable enum class Status { OK, FAIL } ``` The plugin produces a compact **enum serializer** that encodes the constant by name/index using `SerialKind.ENUM`, not a class structure with elements. `@SerialName` on constants customizes names. ### 3. `object` singletons ```kotlin @Serializable object Singleton ``` Generates an **object serializer** whose descriptor has zero elements — it writes/reads an empty structure and always returns the singleton. ### 4. Sealed / polymorphic hierarchies A `@Serializable sealed class` doesn't get a normal concrete-class body; it gets a **sealed/polymorphic serializer** that, on encode, writes a type discriminator and delegates to the concrete subtype's generated serializer. Subclasses still get their own structural codegen. ### 5. Built-ins and external types Primitives, `String`, `List`, `Map`, `Pair`, etc. already have library-provided serializers — no codegen needed. For a third-party class you can't annotate, you write a `KSerializer` by hand or use **contextual** resolution; there's nothing for the plugin to generate. ## What's still generated even with `with =` Even when you supply a serializer, annotating the type with `@Serializable(with=...)` makes `Type.serializer()` resolvable and lets nested `@Serializable` classes reference it automatically. Without the annotation you'd have to pass your serializer explicitly everywhere. ## Mental model `@Serializable` = "make this type's serializer discoverable." The **body** is generated only when you don't provide one and the type is a concrete data-bearing class; enums, objects, sealed types, and custom-`with` cases each take a specialized or user-supplied path.

  • If you use @Serializable(with = X::class), what does the plugin still do?
    It makes Type.serializer() resolve to X and registers discoverability so nested types can reference it, but it does not generate serialize/deserialize bodies.
  • Why don't enums get a per-property structure?
    An enum constant is a single value, not a record of fields, so the plugin emits an ENUM-kind serializer that encodes the constant by index/name.

saying these in an interview costs you the question

  • Thinking @Serializable always generates serialize/deserialize even with with =
  • Believing you can't customize a single property's serializer
  • Assuming enums and objects get the same structural codegen as data classes
  • Not knowing sealed types use a polymorphic serializer
  • Trying to annotate a third-party class you don't own

context