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.

Kotlin Gotchas: Why Your ?.let Sometimes Fails to Compile

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?

Types

class Example {
    val title: String? = null
}

data class Id(val value: String)

Usage

val example = Example()

example.title?.let {
    Id(example.title)
}

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

- example.title?.let {
+ example.title?.let { _ ->
      Id(example.title)
  }

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

  example.title?.let {
-     Id(example.title)
+     Id(it)
  }

Personally, I would go a step further and favour the more concise call site:

- example.title?.let {
-     Id(it)
- }
+ example.title?.let(::Id)

This is an example of point-free style.


Using Point Free Style

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.

example.title?.let { Id(it) }
example.title?.let(::Id)

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.

Generic Algorithms Without Constraints

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:

struct InputA { let name: String; let tag: String? }
struct InputB { let name: String; let tag: String? }

struct Output {
    let name: String
    let tag: String

    init(inputA: InputA, tag: String) {
        self.name = inputA.name
        self.tag = tag
    }

    init(inputB: InputB, tag: String) {
        self.name = inputB.name
        self.tag = tag
    }
}

func processInputAs(inputs: [InputA]) -> [Output] {
    inputs
        .compactMap { input in
            input.tag.map { Output(inputA: input, tag: $0) }
        }
}

func processInputBs(inputBs: [InputB]) -> [Output] {
    inputBs
        .compactMap { input in
            input.tag.map { Output(inputB: input, tag: $0) }
        }
}
data class InputA(val name: String, val tag: String?)
data class InputB(val name: String, val tag: String?)

data class Output private constructor(val name: String, val tag: String) {
    constructor(inputA: InputA, tag: String) : this(inputA.name, tag)
    constructor(inputB: InputB, tag: String) : this(inputB.name, tag)
}

fun processInputAs(inputs: List<InputA>): List<Output> {
    return inputs
        .mapNotNull {
            it.tag?.let { tag -> Output(inputA = it, tag = tag) }
        }
}

fun processInputBs(inputs: List<InputB>): List<Output> {
    return inputs
        .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.

protocol Input {
    var name: String { get }
    var tag: String? { get }
}

struct InputA: Input {
    let name: String
    let tag: String?
}

struct InputB: Input {
    let name: String
    let tag: String?
}

struct Output {
    let name: String
    let tag: String

    init(input: Input, tag: String) {
        self.name = input.name
        self.tag = tag
    }
}

func processInputs(inputs: [Input]) -> [Output] {
    inputs
        .compactMap { input in
            input.tag.map { Output(input: input, tag: $0) }
        }
}
interface Input {
    val name: String
    val tag: String?
}

data class InputA(override val name: String, override val tag: String?): Input
data class InputB(override val name: String, override val tag: String?): Input

data class Output private constructor(val name: String, val tag: String) {
    companion object {
        operator fun <T: Input> invoke(input: T, tag: String) = Output(input.name, tag)
    }
}

fun processInputs(inputs: List<Input>): List<Output> {
    return inputs
        .mapNotNull {
            it.tag?.let { tag -> Output(input = it, tag = tag) }
        }
}

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.

func processInputs(inputs: [??????]) -> [Output] {
    inputs
        .compactMap { input in
            input.???.map { ??????(input: input, tag: $0) }
        }
}
fun processInputs(inputs: List<?????>): List<Output> {
    return inputs
        .mapNotNull {
            it.????.let { tag -> ??????(input = it, tag = tag) }
        }
}

Solving the Problem

For the first point, we can’t avoid not knowing the type - there’s nothing common to constrain to. So we’ll have to just introduce a type parameter.

func processInputs<T>(inputs: [T]) -> [Output] {
    inputs
        .compactMap { input in
            input.???.map { ??????(input: input, tag: $0) }
        }
}
fun <T> processInputs(inputs: List<T>): List<Output> {
    return inputs
        .mapNotNull {
            it.????.let { tag -> ??????(input = it, tag = tag) }
        }
}

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.

func processInputs<T>(
    inputs: [T],
    tag: (T) -> String?
) -> [Output] {
    inputs
        .compactMap { input in
            tag(input).map { ??????(input: input, tag: $0) }
        }
}
fun <T> processInputs(
    inputs: List<T>,
    tag: (T) -> String?
): List<Output> {
    return inputs
        .mapNotNull {
            tag(it)?.let { tag -> ??????(input = it, tag = tag) }
        }
}

Finally, we need to teach the function how to create the new outputs.

func processInputs<T>(
    inputs: [T],
    tag: (T) -> String?,
    makeOutput: (T, String) -> Output
) -> [Output] {
    inputs
        .compactMap { input in
            tag(input).map { makeOutput(input, $0) }
        }
}
fun <T> processInputs(
    inputs: List<T>,
    tag: (T) -> String?,
    makeOutput: (T, String) -> Output
): List<Output> {
    return inputs
        .mapNotNull {
            tag(it)?.let { tag -> makeOutput(it, tag) }
        }
}

With this helper function in place we can update our original processInputAs and processInputBs functions to call through to the shared code.


Pulling it Together

func processInputAs(inputs: [InputA]) -> [Output] {
    processInputs(inputs: inputs, tag: \.tag, makeOutput: Output.init)
}

func processInputBs(inputs: [InputB]) -> [Output] {
    processInputs(inputs: inputs, tag: \.tag, makeOutput: Output.init)
}

private func processInputs<T>(
    inputs: [T],
    tag: (T) -> String?,
    makeOutput: (T, String) -> Output
) -> [Output] {
    inputs
        .compactMap { input in
            tag(input).map { makeOutput(input, $0) }
        }
}
fun processInputAs(inputs: List<InputA>): List<Output> {
    return processInputs(inputs, InputA::tag, Output.Companion::invoke)
}

fun processInputBs(inputs: List<InputB>): List<Output> {
    return processInputs(inputs, InputB::tag, Output.Companion::invoke)
}

fun <T> processInputs(
    inputs: List<T>,
    tag: (T) -> String?,
    makeOutput: (T, String) -> Output
): List<Output> {
    return inputs
        .mapNotNull {
            tag(it)?.let { tag -> makeOutput(it, tag) }
        }
}

Conclusion

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.