Mastering kotlinx.serialization: Advanced Techniques, Custom Serializers and Performance
Explore the powerful features of kotlinx.serialization with this comprehensive guide. Every technique here runs in production in Kore, a Kotlin Multiplatform library that writes Minecraft data packs, and every performance number comes from a measured commit.
1. Introduction to kotlinx.serialization
kotlinx.serialization is a robust, multiplatform serialization library for Kotlin, designed to effortlessly convert Kotlin objects to and from various data formats such as JSON, ProtoBuf, CBOR, and more. A compiler plugin generates a KSerializer for every @Serializable class at build time, so the library itself needs no reflection.
Why Advanced Techniques?
While the default serialization capabilities are sufficient for many use cases, complex projects often demand custom serialization strategies to handle specific requirements like polymorphism, custom naming conventions, or performance optimizations. Mastering these advanced techniques ensures your serialization logic remains maintainable, efficient, and tailored to your application's needs.
The Setup Behind This Guide
Kore serializes hundreds of Minecraft formats (advancements, predicates, worldgen, item components, chat components) to JSON and to NBT, Minecraft's binary tag format. It uses:
- Kotlin 2.4 and kotlinx.serialization 1.11.
- Kotlin Multiplatform, targeting the JVM and JS (Kore also runs in a browser playground).
kotlin-reflectcalls likememberPropertiesorcreateType()don't exist on JS, so every serializer here is reflection-free. - knbt for the NBT format. The NBT branches in the snippets below are optional, drop them if you only target JSON.
- KSP to generate the code reflection would otherwise compute at runtime.
You can explore Kore at kore.ayfri.com or on GitHub at github.com/Ayfri/Kore.
2. Crafting Custom Serializers
Custom serializers allow you to override the default serialization behavior, providing fine-grained control over how your objects are serialized and deserialized.
When to Customize
- Non-Standard Data Formats: When dealing with APIs that expect a specific data structure not directly supported by default serializers.
- Shape Simplification: When a class should collapse to a bare value in some cases, like
trueinstead of{"enabled":true}. - Polymorphic Serialization: When the built-in class discriminator doesn't match the format you have to produce.
- Performance: When the default path builds intermediate trees or loads classes you don't need.
Implementing KSerializer
A KSerializer<T> has three members: a descriptor describing the serialized shape, serialize and deserialize. Here's a base class for every enum that serializes to a transformed string, with a lookup map built once so decoding is a single hash lookup instead of a scan over the entries:
Explanation:
- Descriptor: A
STRINGprimitive, since every entry becomes a plain JSON string. - serialize: Writes the result of the
encodetransform. - deserialize: Looks the string up in a map built on first use, and throws a
SerializationExceptionwith the bad value instead of a bareNoSuchElementException.
3. Enum Serialization Strategies
Enums often require specific serialization formats to align with backend expectations or external APIs. For a one-off rename, @SerialName("...") on an entry is enough. When a whole family of enums follows the same rule, a reusable serializer saves you from annotating every entry.
UppercaseSerializer and LowercaseSerializer
Both are one-liners on top of EnumStringSerializer:
Usage Example:
Resulting JSON:
Custom Strings Through a Transform
The transform handles the entries whose serialized name isn't derivable from the Kotlin name. Since decoding uses the same transform, the custom strings round-trip too:
A camelCase serializer is the same one-liner with a camelCase() string helper as the transform.
EnumOrdinalSerializer
Enumerations have a predefined order based on their declaration sequence. The EnumOrdinalSerializer serializes enums based on their ordinal values:
Keep in mind that ordinals change when someone reorders the entries, so only use them for formats you control on both ends.
4. Wrapping the Generated Serializer with @KeepGeneratedSerializer
Most custom serializers only need to tweak the output of the plugin-generated one: inline a property, drop a wrapper object, rename a key. The naive way rebuilds the object by hand with reflection (memberProperties, getter.call), which is slow, JVM-only and ignores @SerialName unless you reimplement it.
@KeepGeneratedSerializer keeps the plugin serializer around when you set your own with @Serializable(with = ...), and exposes it through generatedSerializer() in the companion. Your serializer then delegates to it and post-processes the resulting tree:
Things to know:
- It's an
@ExperimentalSerializationApiand requires Kotlin 2.0.20 or higher. - It's not allowed on classes involved in polymorphism (interfaces, sealed classes, abstract classes). Section 7 covers those.
- The delegate already applies
@SerialName, defaults and nested serializers, so your code never touches properties directly.
Respecting the JsonNamingStrategy
Once you post-process a JsonObject, you look keys up by name. If the Json instance uses a naming strategy such as JsonNamingStrategy.SnakeCase, retryCount is written as retry_count, and a lookup on the Kotlin name silently misses. This helper asks the strategy for the real key:
A naming strategy can also honor your own annotations. Kore's strategy reads a @JsonSerialName annotation (marked @SerialInfo, so it lands in the descriptor) before falling back to snake_case:
5. Simplifying Property Serialization with InlineAutoSerializer
The InlineAutoSerializer serializes a single-property wrapper as its property value directly, like a value class, without the limitations of actual inline classes: the wrapper can extend a sealed class, hold a var or have several constructors.
Purpose
- Property Serialization: Serialize a wrapper as its only property, in both directions.
- No Reflection: A getter lambda and a constructor reference replace
KProperty1andcreateInstance(). - More Flexible than Inline Classes: Value classes can't extend classes,
InlineAutoSerializerhas no such limit.
Usage Patterns
Example Usage:
Resulting JSON:
Explanation:
- Serializer Implementation:
Storage::itemsreads the value,::Storagerebuilds the wrapper when decoding. - The
serialNameparameter:SerialDescriptor(name, original)copies a descriptor under a new name. Two subtypes of the same sealed family wrapping the same type (say twoList<String>wrappers) would otherwise share a serial name, and a lookup by serial name (section 7) couldn't tell them apart.
6. Optimizing JSON Structures
Many formats accept a short form and a long form of the same value. These serializers write the short form whenever it carries the same information, and accept both when reading.
SinglePropertySimplifierSerializer
Collapses an object to one property's value when it's the only property written:
The singleValueAt helper works on any Map<String, E>, so Kore adds an is NbtEncoder branch doing the same on a knbt NbtCompound.
Example Usage, with the Config class from section 4:
retryCount is omitted from the first output because it equals its default and encodeDefaults is false by default, which leaves enabled as the only key.
EitherInlineSerializer
Some formats accept one of several properties written bare, like a target given as either a name or an id. EitherInlineSerializer writes the first non-null listed property bare, and decodes a bare value by trying each property in turn:
Example Usage:
Decoding 42 first tries {"name":42}, which a non-lenient Json rejects since name is a string, then {"id":42}, which works.
Transforming the Tree with JsonTransformingSerializer
For JSON-only rewrites, the built-in JsonTransformingSerializer gives you hooks on the tree. Kore writes a worldgen provider as its bare value when its type is minecraft:constant:
{"type":"minecraft:constant","value":64} becomes 64, every other provider keeps its object. Override transformDeserialize the same way to accept the short form when reading.
7. Polymorphic Serialization with Namespaces
The built-in polymorphism writes a class discriminator holding the serial name of the subtype, so you end up adding @SerialName("namespace:type") to every subclass, which is tedious and error-prone. It also can't write a subtype that serializes to a bare array or primitive, nor move the fields into a nested property. A custom serializer handles all of this, with no per-class annotation.
Dispatching Without Reflection: SealedDispatcher
Serializing a sealed family needs the serializer of the value's subtype. Reflection (serializer(value::class.createType())) is JVM-only and slow, so the family gets a small object answering two questions:
Writing these by hand for dozens of families would be its own maintenance problem, so KSP generates them (section 8).
NamespacedPolymorphicSerializer
The serializer derives each subtype's name from its descriptor's serial name (com.example.Shape.Circle becomes circle, a @SerialName wins when present), then writes it under type with a namespace. The content and wrapping logic works on any Map<String, E>, so the same code handles a JsonObject and a knbt NbtCompound:
Kore's version hardcodes the minecraft: namespace behind a useMinecraftPrefix flag, the namespace parameter here generalizes it.
Benefits
- No Annotation Per Subclass: Names come from the descriptor,
@SerialNamestays available as an override. - Bare Subtypes: A subtype serializing to an array or a primitive is written as-is, and decoded by trying each subtype.
- Format-Agnostic: One wrapping logic for JSON and NBT, thanks to the generic
Map<String, E>helpers. - Fails Fast: Two subtypes sharing a serial name throw on first decode instead of silently picking one.
Example Usage
Sealed classes and sealed interfaces both work:
Encode through the base type (encodeToString<Shape>): with the static type Circle, the plugin picks Circle.serializer() and no type is written. Resulting JSON:
You have many options to customize the serializer:
outputName: The name of the key that will contain the namespace and the type.skipOutputName: Iftrue, theoutputNamekey won't be added.moveIntoProperty: If notnull, the fields are moved into a property with the given name.namespace: The namespace added before the type,nullfor none.skipEmptyOutput: WithmoveIntoProperty, skips the nested property when the subtype has no fields.contentName: Maps a serial name to the written type name.
Example with moveIntoProperty:
Resulting JSON:
Kore's advancement triggers use exactly this, with outputName = "trigger" and moveIntoProperty = "conditions".
Enum-Like Sealed Families
Some formats mix bare names and typed objects in the same field: "override" for a case without options, {"type": "blend_to_gray", "brightness": 0.5, "factor": 1.0} for one with options. A sealed family models it with data object and data class leaves, and EnumLikeSerializer writes each leaf in the right shape:
A data object serializes to {}, which is how the serializer tells a bare case from a configurable one.
8. Generating Polymorphic Serializers with KSP
@GeneratedSealedSerializer is a plain marker annotation with SOURCE retention:
A KSP processor finds every annotated sealed type, walks getSealedSubclasses() down to the concrete leaves (deduplicated, so a diamond hierarchy emits each leaf once), and writes a fooSealedSerializer() factory next to it. Here's the heart of the generated file for Kore's 58 advancement triggers:
The processor itself is mostly string building. Its key parts:
Passing the source files of the base type and every leaf to Dependencies keeps KSP incremental: editing one trigger only regenerates its family's file.
Why a when on the Simple Name
The usual generated code is a SealedClassSerializer, the serializer the plugin itself builds for sealed classes. It takes the array of every subtype serializer in its constructor, so the first use of the family creates all of them and loads all their classes, even to write a single subtype. Switching to a dispatcher that only creates the serializer it needs took the first advancement trigger from 61 ms and 186 loaded classes to 4 ms and 9 classes on a cold JVM (c92ceb4).
Inside the dispatcher, a when on the simple name beats an is chain: on the JVM, every is Foo check needs the class Foo loaded, so reaching the last branch of a 58-case chain loads 58 classes, while a when on a string only loads the class of the branch it takes.
The simple name comes from an expect/actual function, since the cheapest call differs per platform:
The is chain stays as the else branch for two cases: homonym subtypes (two leaves named Item in different packages get no name branch) and minified production JS, where class names no longer match the source.
Open Hierarchies: Generated Serializer Maps
For a base type that isn't sealed (user code can add subtypes), a @GeneratedSerializerMap processor walks every class in the compilation, keeps the concrete subtypes of the base, and emits a plain map:
A hand-written lookup next to it falls back to a base serializer for user subtypes the processor can't see:
A map lookup on value::class replaces serializer(value::class.createType()), and works on JS. Unlike a SealedDispatcher, building this map creates every serializer in it (section 11 explains why that costs), which is the trade-off for a hierarchy with no closed list of subtypes to switch on. The processor resolves supertypes through a shared cache, so a hierarchy common to many candidates resolves once instead of once per candidate.
9. Handling Complex Data Types
Complex data types like tuples or lists of mixed types require specialized serializers to keep a compact, readable output.
TripleAsArraySerializer
Serializes Kotlin Triple objects as JSON arrays. A list descriptor normally has one element type, so a small custom descriptor gives each index its own:
The plugin passes the serializers of A, B and C to the constructor, so the typealias works with any element types.
Example Usage:
Resulting JSON:
InlinableListSerializer
Writes a single-element list as its element, and reads both forms back:
The ListSerializer is built once in the constructor instead of on every call.
Usage Example:
Bridging Two Formats: NbtAsJsonSerializer
A raw NBT value (NbtTag) inside a class that's also written as JSON needs a serializer that speaks both. With an NBT encoder, it writes the tag as-is, with a JSON encoder it converts the tag to a JsonElement. The reverse conversion has to guess tag types, and NBT lists hold a single tag type, so only a mixed array falls back to strings:
[1,2] stays a list of ints, [1,"a"] becomes a list of strings.
10. Breaking Initialization Cycles with LazySerializer
A serializer declared as a data object builds its state when its class initializes. If class A's serializer reads B.serializer().descriptor while B's serializer reads A's, one of them reads the other while it's still initializing, gets a null field, and the JVM wraps the failure in an ExceptionInInitializerError. A type recursing into itself is fine, the plugin-generated serializer already handles that lazily, the problem only appears between two different classes.
The fix is to give the descriptor upfront and build the real serializer on first use:
Usage Example, an entity predicate that matches its passenger with another entity predicate:
Note delegate.value instead of by lazy: section 11 explains why.
11. Performance Optimization: Measured Results
Serialization in a DSL library is rarely a throughput problem: it's the cold start, the first call that initializes serializers and loads classes. Every number below comes from a Kore commit, with its measurement method.
| Change | Before | After | Measured on |
|---|---|---|---|
Sealed family: generated SealedDispatcher instead of a SealedClassSerializer creating every subtype serializer (c92ceb4) |
61 ms, 186 classes | 4 ms, 9 classes | First advancement trigger, cold JVM |
| Same change, particles | 18 ms | 9 ms | Cold JVM |
| Same change, actions | 16 ms | 9 ms | Cold JVM |
Generated when instead of a map of 148 factory lambdas (67fcde6) |
101 ms | 3 ms | First tag of a pack, cold JVM |
Lazy Json and a when instead of a map of every chat component serializer (96e2357) |
76 ms | 24 ms | First text component, cold JVM |
| Selector arguments rendered directly instead of through a JSON tree escaped then unescaped (2a7336e) | 111 ms, ~1.3 µs per render | 45 ms, ~0.5 µs per render | First selector, cold JVM |
| Selector options rendered directly instead of through a JSON tree (1891fba) | 163 ms | 53 ms | First selector, cold JVM |
Single-value AbstractEncoder instead of encodeToJsonElement (db088a3) |
186 ns per call (stdev 16 ns) | 116 ns per call (stdev 9 ns) | Median of 15 warm JVM rounds |
lazy(...) read through .value instead of a by lazy delegate (bdee8ce) |
40 ms | 12 ms | LazySerializer decoding 20,000 values, Kotlin/JS |
| No serializer on ~1,300 generated enums nothing serializes (2bf85a5) | 9,706 classes, 24.4 MB dev JS library, 71.7 s JVM compile | 8,387 classes, 23.4 MB dev JS library, 60.4 s JVM compile | Clean builds, compile times averaged |
Load Only What You Serialize
mapOf("text" to TextComponent.serializer(), ...) calls every serializer() when the map is built, which initializes every serializer and loads every class in it, even if you only ever write one type. A when does the same job lazily:
The same goes for a SealedDispatcher: serializerOf loads one subtype, and only decoding reaches serializersBySerialName, which loads the whole family.
Don't Round-Trip Through a Tree
encodeToJsonElement followed by encodeJsonElement builds a whole JsonElement tree only to walk it again. When a serializer doesn't need to inspect the output, delegate straight to the encoder:
The same applies to string rendering: building a JsonObject to format a command argument, then unescaping the result, costs more than appending to a StringBuilder.
Read a Single Value with a Custom AbstractEncoder
To get the serialized name of an enum or a value class as a String, Json.encodeToJsonElement(value).jsonPrimitive.content builds a tree for one value. A tiny encoder keeps it instead:
encodeEnum stores the entry's serial name, so @SerialName on enum entries is respected.
Reuse Your Json Instances
Json { ... } is not a cheap value to rebuild in a getter. Each instance owns a schema cache, and with a naming strategy, the name maps computed per descriptor live in that cache: a fresh Json per call recomputes them every time. Build it once, or cache it behind the settings it depends on:
When a Json is only needed on some paths, wrap it in a top-level lazy so the first unrelated call doesn't pay for it.
by lazy on Kotlin/JS
On Kotlin/JS, each read of a by lazy property built a KProperty object in Kore's measurements, since a delegated property passes its metadata to getValue. Holding the Lazy and reading .value skips it, which took LazySerializer decoding from 40 ms to 12 ms on 20,000 values:
Smaller Wins That Add Up
- Lookup maps built once:
values.first { it.name.lowercase() == value }scans the entries on every decode, a map built on first use doesn't (seeEnumStringSerializer). - Hoisted regexes: a
Regex(...)insidesnakeCase()compiles the pattern on every call. Top-levelvals compile it once, which matters when every polymorphic type name goes through it. - Fewer generated serializers: every
@Serializableclass costs a generated serializer class. Don't annotate types you never serialize, it shrinks the binary and the compile time (the last row of the table). - Measure cold and warm separately: a cold JVM run shows class loading and initialization, warm rounds with several samples and their variance show the steady state. They point to different fixes.
12. Best Practices and Performance Tips
Serializer Reusability
- Generic Base Classes: Write
open class FooSerializer<T>(...)once, then declare onedata objectper type. Kore has a handful of base serializers for hundreds of classes. - Delegate, Don't Rebuild: Wrap
generatedSerializer()and post-process its output instead of re-encoding properties by hand. - Check Before Writing a New One: Most new shapes are an existing serializer with another parameter.
Multiplatform Compatibility
- No
kotlin-reflect:memberProperties,createType(),isAccessibleandcreateInstance()are JVM-only. Use getters, constructor references and generated code instead. expect/actualfor Hot Paths: When the cheapest call differs per platform, like the class simple name, split it.- Watch Minification: Class names on minified JS don't match the source, keep a fallback for anything keyed on them.
Error Handling
- Descriptive Errors: Name the type and the bad value, like
'foo' is not an entry of this enum.orNo subtype 'foo' in Shape. - Throw
SerializationException: Callers catching serialization failures expect it, not aNoSuchElementExceptionfromfirst {}. - Fail at the Source: Check invariants like unique serial names where the data is built, not where it's used.
Testing
Test every custom serializer both ways, on the exact output shape:
13. FAQ
Does kotlinx.serialization use reflection?
Not by itself: the compiler plugin generates serializers at build time. Reflection comes from your own code, like kotlin-reflect calls in a custom serializer. Those don't exist on Kotlin/JS or Native, which is why this guide replaces them with generatedSerializer(), getters, constructor references and KSP.
How do I serialize a sealed interface without a class discriminator?
For a plain object without any type field, set classDiscriminatorMode = ClassDiscriminatorMode.NONE on your Json, keeping in mind kotlinx.serialization can't decode polymorphic classes written in this mode. For a custom type field (namespaced names, bare subtypes, nested content), use a custom serializer like the NamespacedPolymorphicSerializer of section 7.
How do I keep the generated serializer when I write a custom one?
Annotate the class with @KeepGeneratedSerializer next to @Serializable(with = ...), then call generatedSerializer() from the companion. It requires Kotlin 2.0.20 or higher and isn't allowed on sealed, abstract or polymorphic classes.
Why is my first serialization call so slow?
The first call initializes serializers and loads classes. Look for maps or lists that build every serializer upfront, is chains over large sealed families and Json instances rebuilt on every call (section 11).
How do I fix an ExceptionInInitializerError thrown by a serializer?
It usually means two classes' serializers need each other during class initialization. Give one of them a LazySerializer with an explicit descriptor (section 10).
How do I write one serializer for JSON and another format?
Check the encoder type: is JsonEncoder gives you encoder.json, is NbtEncoder (knbt) gives you encoder.nbt, and an else branch delegates with encodeSerializableValue. Keep the logic on Map<String, E> and both formats share it.
14. Conclusion
Mastering kotlinx.serialization empowers you to handle complex serialization scenarios with ease and efficiency. Delegating to the generated serializer keeps custom serializers short and reflection-free, KSP replaces what reflection would compute at runtime, and a few habits (load only what you serialize, skip intermediate trees, reuse Json instances) keep the first call fast.
Next Steps
- Explore More: Dive deeper into the kotlinx.serialization guide to uncover additional features and capabilities.
- Experiment: Apply the discussed techniques in your projects, measure cold and warm, and keep only what the numbers support.
- Contribute: Engage with the Kotlin community to share insights, seek feedback, and contribute to the ongoing development of serialization tools.
Appendix
Thanks
For their help and insights on the subject.
Reference Implementation
All in Kore's serializers package:
- Enum Serializers: SerializationUtils.kt (
EnumStringSerializer), UppercaseSerializer.kt, LowercaseSerializer.kt - Shape Serializers: InlineAutoSerializer.kt, SinglePropertySimplifierSerializer.kt, EitherInlineSerializer.kt, ProviderSerializer.kt
- Polymorphic Serializers: NamespacedPolymorphicSerializer.kt, EnumLikeSerializer.kt, SealedDispatcher.kt
- KSP Processors: SealedSerializerProcessor.kt, SerializerMapProcessor.kt
- Complex Types: TripleAsArraySerializer.kt, InlinableListSerializer.kt, NbtAsJsonSerializer.kt, LazySerializer.kt
Further Reading
- kotlinx.serialization GitHub Repository
- Official Kotlin Serialization Documentation
- Custom serializers guide
- Polymorphism guide
- JSON features guide
- KSP overview
Keep reading
-
4k-minecraft: A Deflate Stream Without 10 Bytes
How 4k-minecraft, a WebGPU Minecraft in one HTML file of 4096 bytes, carries its own code, and why it only fits thanks to a deflate encoder that never writes the 10 bytes the HTML parser would break.
4k-minecraftminecraft 4ksize codingcode golfdeflate+10 -
How I Built This Portfolio in Kotlin with Kobweb
The story and the tech behind ayfri.com, a portfolio written entirely in Kotlin with Kobweb and Compose for Web.
kotlinkobwebcompose for webcompose htmlkotlin/js+5 -
Build a Boss Fight Datapack with Kore
Build a complete Minecraft boss fight in Kotlin with Kore - a legendary weapon with item components, a custom boss, an OOP boss bar, particle VFX, and a state machine driving the whole fight.
minecraftdatapackkorekotlintutorial+8
Want these articles first in Google Search and AI Mode? Add ayfri.com as a preferred source on Google
Pierre Roy