Kotlinx.serialization has become the standard serialization library for Kotlin, offering compile time code generation, multiplatform support, and format agnostic design. Most developers know the surface level usage: annotate a class with @Serializable, and the library handles the rest. But the deeper question remains: how does the compiler plugin actually generate serializers? What happens between your @Serializable annotation and the working KSerializer<T> implementation?
In this article, you’ll dive deep into the internal mechanisms of the kotlinx.serialization compiler plugin, exploring how the two pass IR generation pipeline works, how the plugin generates the $serializer nested class with its descriptor, serialize, and deserialize methods, and the clever optimizations like golden mask validation that make deserialization both safe and performant. Also, you’ll explore with the real use cases of kotlinx.serialization from the RevenueCat SDK.
The fundamental problem: Reflection is expensive and platform dependent
Consider a simple data class that you want to serialize to JSON:
1data class User(
2 val id: Long,
3 val name: String,
4 val email: String?,
5 val createdAt: Date
6)
Without code generation, serialization libraries must use reflection to discover properties, their types, and their values at runtime. This approach has significant drawbacks:
1// Reflection-based serialization (simplified)
2fun serializeWithReflection(obj: Any): Map<String, Any?> {
3 val result = mutableMapOf<String, Any?>()
4 obj::class.memberProperties.forEach { prop ->
5 prop.isAccessible = true
6 result[prop.name] = prop.get(obj)
7 }
8 return result
9}
The problems with this approach are substantial. First, reflection is slow. Discovering properties, checking accessibility, and invoking getters at runtime adds overhead to every serialization operation. Second, reflection is platform dependent. Kotlin/Native and Kotlin/JS have limited or different reflection capabilities than the JVM. Third, there is no compile time safety. Type mismatches and missing properties are only discovered at runtime.
The kotlinx.serialization plugin solves these problems by generating serialization code at compile time. The generated code knows exactly which properties exist, their types, and how to read and write them, with no reflection required.
The compiler plugin architecture
The kotlinx.serialization compiler plugin operates as an extension to the Kotlin compiler, hooking into multiple stages of the compilation pipeline. The plugin is organized into several components that work together:
- Frontend (K1 or K2): Detects
@Serializableannotations and generates synthetic declarations (the$serializerclass, companion methods). - IR Generation: Transforms the intermediate representation in two passes. Pass 1 creates function stubs, Pass 2 generates method bodies.
- Backend: Emits platform specific code (JVM bytecode, JavaScript, or Native binaries).
The plugin registers itself through a CompilerPluginRegistrar that hooks into these extension points. The critical registration looks conceptually like this:
1class SerializationComponentRegistrar : CompilerPluginRegistrar() {
2 override fun ExtensionStorage.registerExtensions(configuration: CompilerConfiguration) {
3 // Register resolve extension for synthetic declarations
4 SyntheticResolveExtension.registerExtension(SerializationResolveExtension())
5
6 // Register IR generation extension for code generation
7 IrGenerationExtension.registerExtension(SerializationLoweringExtension())
8
9 // Register K2/FIR extensions for new compiler frontend
10 FirExtensionRegistrarAdapter.registerExtension(FirSerializationExtensionRegistrar())
11 }
12}
The key observation here is that the plugin operates at multiple levels. It first generates synthetic declarations during the resolve phase, making the compiler aware of generated classes and methods. Then it generates the actual implementation bodies during the IR lowering phase.
Two pass IR generation: Stubs before bodies
One of the most interesting aspects of the kotlinx.serialization plugin is its two pass IR generation strategy. Understanding why this is necessary requires understanding how generated code references itself.
Consider what the plugin needs to generate for a serializable class:
1@Serializable
2data class User(val name: String, val age: Int)
3
4// The plugin generates:
5// 1. A nested $serializer class implementing KSerializer<User>
6// 2. A companion object with serializer() method
7// 3. Methods that reference each other
The generated serialize() method might call a helper method like write$Self(). The deserialize() method needs to call the class constructor. These methods reference each other and reference other generated elements. If the plugin tried to generate everything in a single pass, it would encounter undefined references.
The solution is a two pass approach:
1class SerializationLoweringExtension : IrGenerationExtension {
2 override fun generate(moduleFragment: IrModuleFragment, pluginContext: IrPluginContext) {
3 // Pass 1: Create all declarations without bodies
4 val preGenerator = SerializerClassPreLowering(pluginContext)
5 moduleFragment.files.forEach { file ->
6 preGenerator.runOnFileInOrder(file)
7 }
8
9 // Pass 2: Generate implementation bodies
10 val generator = SerializerClassLowering(pluginContext)
11 moduleFragment.files.forEach { file ->
12 generator.runOnFileInOrder(file)
13 }
14 }
15}
In the first pass, the plugin creates function declarations with empty bodies. This establishes all the symbols that generated code might reference. In the second pass, the plugin fills in the actual implementation bodies, now able to reference any symbol created in the first pass.
This is elegant. The two pass strategy mirrors how compilers handle forward declarations in languages like C, but applied to code generation within a single compilation unit.
The generated serializer structure
When you annotate a class with @Serializable, the plugin generates a nested class named $serializer that implements GeneratedSerializer<T>. Let’s trace through what gets generated for a simple class:
1@Serializable
2data class Person(
3 val name: String,
4 val age: Int,
5 val email: String? = null
6)
The plugin generates a structure that conceptually looks like this:
1data class Person(val name: String, val age: Int, val email: String? = null) {
2
3 // Generated nested serializer class
4 internal object `$serializer` : GeneratedSerializer<Person> {
5
6 override val descriptor: SerialDescriptor = buildClassSerialDescriptor("Person") {
7 element<String>("name")
8 element<Int>("age")
9 element<String?>("email", isOptional = true)
10 }
11
12 override fun serialize(encoder: Encoder, value: Person) {
13 val composite = encoder.beginStructure(descriptor)
14 composite.encodeStringElement(descriptor, 0, value.name)
15 composite.encodeIntElement(descriptor, 1, value.age)
16 if (value.email != null || composite.shouldEncodeElementDefault(descriptor, 2)) {
17 composite.encodeNullableSerializableElement(
18 descriptor, 2, String.serializer(), value.email
19 )
20 }
21 composite.endStructure(descriptor)
22 }
23
24 override fun deserialize(decoder: Decoder): Person {
25 val composite = decoder.beginStructure(descriptor)
26 var name: String? = null
27 var age: Int? = null
28 var email: String? = null
29 var seen = 0
30
31 while (true) {
32 when (val index = composite.decodeElementIndex(descriptor)) {
33 0 -> { name = composite.decodeStringElement(descriptor, 0); seen = seen or 1 }
34 1 -> { age = composite.decodeIntElement(descriptor, 1); seen = seen or 2 }
35 2 -> { email = composite.decodeNullableSerializableElement(
36 descriptor, 2, String.serializer()); seen = seen or 4 }
37 CompositeDecoder.DECODE_DONE -> break
38 else -> throw SerializationException("Unknown index $index")
39 }
40 }
41 composite.endStructure(descriptor)
42
43 // Validate required fields
44 if (seen and 3 != 3) {
45 throwMissingFieldException(seen, 3, descriptor)
46 }
47
48 return Person(
49 name = name as String,
50 age = age as Int,
51 email = email
52 )
53 }
54
55 override fun childSerializers(): Array<KSerializer<*>> = arrayOf(
56 String.serializer(),
57 Int.serializer(),
58 String.serializer().nullable
59 )
60 }
61
62 companion object {
63 fun serializer(): KSerializer<Person> = `$serializer`
64 }
65}
Notice the structure. The $serializer class is an object (singleton) that holds the immutable descriptor and implements the serialization logic. The companion object provides a convenient serializer() accessor.
Descriptor generation: Metadata for format agnostic serialization
The SerialDescriptor is a critical piece of the generated code. It describes the structure of the serializable class in a format independent way, enabling different serialization formats (JSON, Protobuf, CBOR) to use the same serializer implementation.
The plugin generates descriptor initialization code that builds the complete metadata:
1override val descriptor: SerialDescriptor =
2 PluginGeneratedSerialDescriptor("com.example.Person", this, 3).apply {
3 addElement("name", isOptional = false)
4 addElement("age", isOptional = false)
5 addElement("email", isOptional = true)
6 // Annotations are also copied to the descriptor
7 }
The descriptor includes the serial name of the class (which might differ from the class name if @SerialName is used), the number of elements, each element’s name and whether it’s optional, type information for nested serializers, and any @SerialInfo annotations applied to the class or its properties.
This design is clever. By separating the structural metadata from the serialization logic, formats can make intelligent decisions about encoding. A JSON encoder might use the element names directly as keys. A Protobuf encoder might use element indices. The serializer implementation remains the same.
Golden mask optimization: Efficient required field validation
One of the most important optimizations in the generated code is the golden mask pattern for validating required fields during deserialization. When deserializing, the plugin needs to ensure all required (non optional) fields are present in the input.
The naive approach would be to check each field individually:
1// Naive approach
2if (name == null) throw MissingFieldException("name")
3if (age == null) throw MissingFieldException("age")
4// ... for each required field
Instead, the plugin generates bitmask based validation:
1var seen = 0
2
3// During deserialization, set bits as fields are encountered
4when (index) {
5 0 -> { name = ...; seen = seen or 0b001 } // bit 0
6 1 -> { age = ...; seen = seen or 0b010 } // bit 1
7 2 -> { email = ...; seen = seen or 0b100 } // bit 2 (optional)
8}
9
10// Golden mask contains bits for all REQUIRED fields
11val goldenMask = 0b011 // name (bit 0) and age (bit 1) are required
12
13// Single comparison validates all required fields
14if (seen and goldenMask != goldenMask) {
15 throwMissingFieldException(seen, goldenMask, descriptor)
16}
This is a gold. A single bitwise AND operation validates all required fields simultaneously. The golden mask is computed at compile time based on which properties have default values (optional) and which don’t (required).
For classes with more than 32 properties, the plugin generates multiple mask integers:
1var seen0 = 0 // Properties 0-31
2var seen1 = 0 // Properties 32-63
3
4val goldenMask0 = 0b...
5val goldenMask1 = 0b...
6
7if (seen0 and goldenMask0 != goldenMask0 ||
8 seen1 and goldenMask1 != goldenMask1) {
9 throwArrayMissingFieldException(intArrayOf(seen0, seen1),
10 intArrayOf(goldenMask0, goldenMask1),
11 descriptor)
12}
The error reporting is also smart. The throwMissingFieldException function uses the seen mask and golden mask to determine exactly which fields are missing, providing a clear error message without requiring additional bookkeeping.
Serializer resolution: Finding the right serializer for each type
When generating serialization code for a property, the plugin must determine which serializer to use. This resolution follows a priority order:
1fun findSerializerForType(type: IrType, property: IrProperty): IrExpression {
2 // 1. Explicit serializer annotation
3 property.getSerializableWith()?.let { return instantiate(it) }
4
5 // 2. Contextual serializer
6 if (property.hasContextualAnnotation()) {
7 return getContextualSerializer(type)
8 }
9
10 // 3. Primitive types have built-in serializers
11 if (type.isPrimitiveType()) {
12 return getBuiltInSerializer(type) // Int.serializer(), String.serializer(), etc.
13 }
14
15 // 4. Collections and maps
16 if (type.isCollection()) {
17 val elementSerializer = findSerializerForType(type.elementType)
18 return ListSerializer(elementSerializer)
19 }
20
21 // 5. Enum classes
22 if (type.isEnum()) {
23 return getEnumSerializer(type)
24 }
25
26 // 6. Other @Serializable classes
27 if (type.hasSerializableAnnotation()) {
28 return type.classSerializer() // Type.$serializer
29 }
30
31 // 7. Polymorphic fallback for interfaces
32 if (type.isInterface()) {
33 return PolymorphicSerializer(type)
34 }
35
36 throw SerializationException("No serializer found for $type")
37}
This resolution happens at compile time, and the result is baked into the generated code. The generated childSerializers() method returns an array of all serializers needed for the class’s properties, enabling format implementations to introspect the complete serialization structure.
Real world application: Custom serializers in production SDKs
Understanding the generated serializer structure helps when building custom serializers. Production SDKs like RevenueCat’s Android SDK leverage this knowledge to build robust serialization for complex API responses.
For example, when dealing with backend responses that might include unknown enum values or polymorphic types, a custom deserializer with defaults becomes necessary:
1internal abstract class SealedDeserializerWithDefault<T : Any>(
2 private val serialName: String,
3 private val serializerByType: Map<String, () -> KSerializer<out T>>,
4 private val defaultValue: (type: String) -> T,
5 private val typeDiscriminator: String = "type",
6) : KSerializer<T> {
7
8 override val descriptor: SerialDescriptor = buildClassSerialDescriptor(serialName) {
9 element(typeDiscriminator, String.serializer().descriptor)
10 }
11
12 override fun deserialize(decoder: Decoder): T {
13 val jsonDecoder = decoder as? JsonDecoder
14 ?: throw SerializationException("Can only deserialize from JSON")
15 val jsonObject = jsonDecoder.decodeJsonElement().jsonObject
16 val type = jsonObject[typeDiscriminator]?.jsonPrimitive?.content
17
18 return serializerByType[type]?.let { serializerFactory ->
19 jsonDecoder.json.decodeFromJsonElement(serializerFactory(), jsonObject)
20 } ?: defaultValue(type ?: "null")
21 }
22
23 override fun serialize(encoder: Encoder, value: T) {
24 throw NotImplementedError("Serialization not needed for API responses")
25 }
26}
This pattern mirrors how the plugin generates deserializers but adds fallback behavior for unknown types. It reads the type discriminator, looks up the appropriate serializer, and falls back to a default when the type is unknown. This is essential for backward compatibility when servers add new types that older clients don’t recognize.
Similarly, enum deserialization with defaults handles unknown values gracefully:
1internal abstract class EnumDeserializerWithDefault<T : Enum<T>>(
2 private val valuesByType: Map<String, T>,
3 private val defaultValue: T,
4) : KSerializer<T> {
5
6 override val descriptor: SerialDescriptor =
7 PrimitiveSerialDescriptor(defaultValue.javaClass.simpleName, PrimitiveKind.STRING)
8
9 override fun deserialize(decoder: Decoder): T {
10 val key = decoder.decodeString()
11 return valuesByType[key] ?: defaultValue
12 }
13
14 override fun serialize(encoder: Encoder, value: T) {
15 throw NotImplementedError("Serialization not needed")
16 }
17}
These patterns work because they follow the same interface contract that the generated serializers implement. Understanding the generated structure makes building compatible custom serializers straightforward.
K1 vs. K2: Supporting both compiler frontends
The Kotlin compiler is undergoing a major transition from the K1 frontend to the K2 frontend (based on FIR, Frontend Intermediate Representation). The kotlinx.serialization plugin must support both during this transition.
The K1 support uses descriptor based APIs:
1class SerializationResolveExtension : SyntheticResolveExtension {
2 override fun getSyntheticNestedClassNames(thisDescriptor: ClassDescriptor): List<Name> {
3 if (thisDescriptor.hasSerializableAnnotation()) {
4 return listOf(Name.identifier("\$serializer"))
5 }
6 return emptyList()
7 }
8
9 override fun generateSyntheticClasses(
10 thisDescriptor: ClassDescriptor,
11 result: MutableCollection<DeclarationDescriptor>
12 ) {
13 // Generate synthetic $serializer class descriptor
14 }
15}
The K2 support uses the new FIR based APIs:
1class SerializationFirResolveExtension : FirDeclarationGenerationExtension {
2 override fun generateNestedClassLikeDeclaration(
3 owner: FirClassSymbol<*>,
4 name: Name
5 ): FirClassLikeSymbol<*>? {
6 if (name.identifier == "\$serializer" && owner.hasSerializableAnnotation()) {
7 return generateSerializerClass(owner)
8 }
9 return null
10 }
11}
The key observation is that both frontends ultimately feed into the same IR generation phase. The IR lowering code that generates method bodies is shared between K1 and K2. This separation of concerns allows the plugin to support both frontends while maintaining a single implementation of the actual code generation logic.
Conclusion
In this article, you’ve explored the internal mechanisms of the kotlinx.serialization compiler plugin, from its two pass IR generation strategy to the golden mask optimization for required field validation. The plugin transforms simple @Serializable annotations into efficient, type safe serializers without requiring reflection or runtime code generation.
Of course, you don’t have any issues without understanding these internals mechanisms, but it will definitely help you make informed decisions when building custom serializers, debugging serialization issues, or evaluating kotlinx.serialization for your projects in any ways. The design choices, like compile time code generation, format agnostic descriptors, and bitmask validation, reflect careful engineering for both performance and flexibility.
Whether you’re building a multiplatform application that needs consistent serialization across JVM, JS, and Native, implementing custom serializers for complex API responses, or simply curious about how your @Serializable classes become working serializers, this knowledge provides the foundation for working effectively with one of Kotlin’s most important libraries.

