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.
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.
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.
funrouteToCheckout(cart:Cart){/*
* If you find yourself here with a compilation error, ensure that the relevant
* type has a serializer defined.
*/valencodedCart=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
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
funrouteToPage(customTypes:Map<String,CustomType>){/*
* If you find yourself here with a compilation error, ensure that the relevant
* type has a serializer defined.
*/CustomType.serializer()valencodedCustomTypes=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.
Kotlin’s let is a great tool for writing expressive code, but I’ve noticed it can introduce subtle fragility when used the wrong way - especially alongside certain compiler features.
Let’s start with a question - should this code compile?
At first glance, this should compile - and it does. But we can make small, seemingly harmless tweaks that suddenly break it.
Failing to Compile
The first breaking change would be to make title a computed property
class Example {
- val title: String? = null
+ val title: String? get() = null
}
With this change, we get the following error:
Smart cast to ‘String’ is impossible, because ‘title’ is a property that has an open or custom getter.
This error rather cunningly suggests another change that also causes it to fail.
- class Example {
- val title: String? = null
+ open class Example {
+ open val title: String? = null
}
One final change I can think of that breaks for the same underlying reason but is achieved in a different way is to declare Example in a different module from the usage code.
This gives the error
Smart cast to ‘String’ is impossible, because ‘title’ is a public API property declared in different module.
So what’s going on here?
Failing to Smart Cast
We’ve seen Smart cast mentioned in both errors but what does that mean?
A smart cast is when the Kotlin compiler automatically treats a variable as a non-null or a more specific type after it’s checked - but only if it can guarantee the value won’t change in the meantime.
In the original working code, the compiler can see that Example.title is declared as val and cannot be reassigned.
So inside the scope of the ?.let the compiler is able to prove that the value cannot change.
All these breaking changes are just different ways of preventing the compiler from making that guarantee.
There’s another subtle language feature that allows us to write this code without realising the mistake.
Ignoring Closure Arguments
Kotlin allows you to silently ignore closure arguments.
This contrasts with languages like Swift, which require you to explicitly mark when you’re ignoring one.
For example in Swift we cannot implicitly ignore closure arguments so the compiler would force us to make this change
Thinking about this actually points to the root issue, we should use the argument passed to the closure rather than doing the example.title access again.
Recommendation
Now that we understand why, the fix should be clearer.
Instead of relying on a Smart cast and ignoring the lambda argument, we should just use the closure argument it directly:
This means our call site would change like this
Not everyone is a fan of method references because :: looks odd and scary at first.
Once you get used to it, it’s really handy for writing super concise code.
It’s subtle but the second line here packs a punch.
In essence we are taking the data from let and plumbing it straight into the Id constructor.
In the first line we are doing this manually by providing a closure and then invoking the Id constructor.
In the second line we are just composing these functions together.
It’s subtle, especially in such a short example.
With the first line, I have to mentally parse the closure, verify how it is used, and ensure it isn’t modified before being passed to Id.
With the second line I don’t have to do any of that - I just know that the let will feed the value straight into Id.
Conclusion
Keeping in mind that the original code listing worked, we could arguably just not worry about any of this.
I personally think the end code is simpler and more descriptive about the intent but that can be debated.
Also the original code has the issue that unrelated changes can start breaking things, which is something that I think we should always try to avoid.
This is definitely one of those changes that I would suggest in a pull request but feel guilty about not providing a thorough explanation of the reasoning.
This post gives me extended notes I can point to when explaining the change.
It’s often a red flag if we see duplicated code and we generally try to reduce it as much as possible.
When we control all the code, we can massage structures to make this task easier but we don’t always have this flexibility.
This post explores how to remove duplication when two functions share logic but act on unrelated types with no shared interface - using Kotlin and Swift as examples.
Let’s look at a more challenging case.
The Problem
The algorithm itself isn’t important here, but consider the following starting point:
data classInputA(valname:String,valtag:String?)data classInputB(valname:String,valtag:String?)data classOutputprivateconstructor(valname:String,valtag:String){constructor(inputA:InputA,tag:String):this(inputA.name,tag)constructor(inputB:InputB,tag:String):this(inputB.name,tag)}funprocessInputAs(inputs:List<InputA>):List<Output>{returninputs.mapNotNull{it.tag?.let{tag->Output(inputA=it,tag=tag)}}}funprocessInputBs(inputs:List<InputB>):List<Output>{returninputs.mapNotNull{it.tag?.let{tag->Output(inputB=it,tag=tag)}}}
As shown above, both processInputAs and processInputBs are nearly identical.
The issue is that these two functions operate on different input types with no common supertype.
This means we can’t write a simple generic function with a constrained type parameter, because we have no common supertype to constrain against.
So how might we solve this duplication if our types did share a common shape?
When a Shared Constraint Exists
If InputA and InputB did have a common supertype we could trivially create a single function that handles them both.
Unfortunately, in our original framing, InputA and InputB share no common supertype.
This can occur in situations where you don’t own the types as they come from a third-party library or maybe the types are generated with something like OpenAPI.
When I hit this situation, I tend to look at the two functions side by side and highlight the differences.
Reveal The Differences
For these functions the differences are going to be:
The type of the argument
How we access tag (keep in mind the code looks the same but InputA and InputB are distinct types)
How we construct Output.
In the listing below I’ve highlighted these areas with multiple ?s.
Next, we need to teach the function how to read the tag value.
As we only know we have a T we can provide a function that takes a T and returns the String? we expect.
We don’t always need shared supertypes to make algorithms generic.
With a little upfront work teaching the algorithm how to access and create data, we can operate on unrelated types just as effectively.
Both Kotlin and Swift make this especially clean through their support for passing method and initializer references - keeping our higher-order functions readable and expressive.