From Runtime Explosions to Compiler Checked Simplicity

When I’m solving problems I rarely move in a straight line. I tend to circle the goal, trying a handful of bad or awkward ideas before something clicks. With experience those loops get shorter, but they never really go away.

This post is about one of those loops: a small problem in some of our KSP generated code. I’ll walk through a few iterations of the solution and end with a much simpler approach that let the compiler do the hard work instead of us.


The problem

We have some KSP (Kotlin Symbol Processor) code that generates helper functions for routing within our application. Inside the generated functions we were blindly taking in arguments and attempting to serialize them with kotlinx.serialization e.g.

fun routeToCheckout(cart: Cart) {
    val encodedCart = Json.encodeToString(cart)
    ...
}

The issue here is that if Cart is not annotated with @Serializable then this code will explode at runtime, which is less than ideal.


Solutions

Your friend and mine (your LLM of choice) suggested explicitly taking a serializer at the call site. This would force the caller to guarantee that a serializer exists.

In practice though, it felt wrong. Requiring consumers of this generated API to manually thread serializers through their code makes the API harder to use and leaks an implementation detail that callers shouldn’t need to care about.

The other suggestion from the LLM was to use a reified inline function but that was not an option based on the code setup.


Attempt 1

Using the suggestions from the LLM I thought maybe we could just blindly generate the serializer into the body of the function and then the compiler would reject our code if the serializer didn’t exist e.g.

fun routeToCheckout(cart: Cart) {
    val encodedCart = Json.encodeToString(Cart.serializer(), cart)
    ...
}

This works as the compiler will now error if Cart.serializer() does not exist, which is much better than a runtime exception. Granted this does have a less than ideal failure mode as the consumer of this KSP processor could end up with their code not compiling and being pointed to generated code. Whilst not great I was happy with the compromise and we can generate a comment as well to help steer anyone who does end up here e.g.

fun routeToCheckout(cart: Cart) {
    /*
     * If you find yourself here with a compilation error, ensure that the relevant
     * type has a serializer defined.
     */
    val encodedCart = Json.encodeToString(Cart.serializer(), cart)
    ...
}

Never smooth sailing

I applied this latest code to a larger code base and it immediately flagged the code that caused the original issue that prompted this investigation in the first place, which was reassuring. It also highlighted a few other call sites that would exhibit the same breakage but luckily those code paths weren’t executed. More annoyingly the new code found that we sometimes pass more complex types that use Kotlin collections e.g. we had types like List<String> or Map<String, CustomType>. Obviously I didn’t see the wood for the trees and started “making it work” but it got real ugly real quick with all the potential nesting. The serializers in the above cases would be

ListSerializer(String.serializer())
MapSerializer(String.serializer(), CustomType.serializer())

To do this I started thinking about writing a recursive function that would keep traversing through the types building up these serializers and special casing the various Kotlin collection types.


Could we do it simpler?

Luckily at this point I’d already asked my colleague Jack for his thoughts, which was could we do it simpler?. The key insight he’d had after hearing me ramble on about my current progress was that we don’t actually need to reproduce the exact serializer to pass to Json.encodeToString; the root of the problem is that we want to prove that each type mentioned has a serializer.

The new idea was to simply list out all the types outside of the Json.encodeToString function and let the compiler just do its thing. So essentially for the Map<String, CustomType> example the target is something like

fun routeToPage(customTypes: Map<String, CustomType>) {
    /*
     * If you find yourself here with a compilation error, ensure that the relevant
     * type has a serializer defined.
     */
    CustomType.serializer()

    val encodedCustomTypes = Json.encodeToString(customTypes)
    ...
}

We don’t need to worry about checking String.serializer() or MapSerializer exist because we know the library provides those.


Getting our prompt on

Once the target shape was clear, the remaining work became much more mechanical. We needed a way to walk the types involved in a function signature, including any type parameters and extract the set of domain types we cared about.

This was ideal for a quick human/LLM pairing session.


Conclusion

This problem went through several iterations, each one technically “working” but increasingly complex. The turning point came from stepping back and questioning what we were really trying to achieve.

We didn’t need to construct the correct serializer. We didn’t need to mirror kotlinx.serialization’s internal rules. We just needed proof at compile time that the types flowing through our generated APIs were @Serializable.

By narrowing the problem to that single requirement, the solution became smaller, clearer and more robust. It also produced better failures: early, explicit and enforced by the compiler rather than discovered at runtime.

It’s a useful reminder that when a solution starts to grow unwieldy, the answer is often not more code, but a better question.

The best part is that this is now another pattern my brain can store away and recognise more quickly in the future, reducing the number of iterations when I hit similar problems again.