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:
Without code generation, serialization libraries must use reflection to discover properties, their types, and their values at runtime. This approach has significant drawbacks:
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:
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:
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:
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:
The plugin generates a structure that conceptually looks like this:
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:
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:
Instead, the plugin generates bitmask based validation:
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:
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:
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:
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:
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:
The K2 support uses the new FIR based APIs:
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.

