Blog

  • Kotlin IR: Transforming DSL at Compile-Time

    In my previous post, we explored the basics of the Kotlin IR (Intermediate Representation). Today, we’ll move from theory to practice by building a transformer plugin that solves a common DSL design dilemma: Type-safe syntax vs. Runtime flexibility.

    The “Primitive” Limitation

    When building an assertion library, you often want a syntax that feels natural but remains strictly typed:

    Kotlin
    val x: Int = 10
    confirmThat { x } deepMatches { 10 }            // Should compile
    confirmThat { x } deepMatches { lessThan(20) }  // Should compile
    confirmThat { x } deepMatches { "abc" }         // Should FAIL

    Here, deepMatches expects a value of the same type as x. However, to make assertions work, we need lessThan(20) to return a Matcher object, not a primitive Java int.

    Usually, we have two bad options:

    • Wrappers: Force the user to write confirmThat { Box(x) }, which ruins the DSL.
    • Runtime Proxies: Impossible for primitive types like Int or Long.

    The IR Solution: We let the user write code that satisfies the Kotlin compiler (returning Int), then use an IR Transformer to swap that call for a Matcher object before the bytecode is generated.

    Implementing the Transformer

    To perform the swap, we implement an IrElementTransformerVoid. This allows us to intercept function calls and replace them with new expressions.

    1. Identifying the Target

    First, we filter for the specific API call we want to replace:

    Kotlin
    override fun visitCall(expression: IrCall): IrExpression {
        val fqName = expression.symbol.owner.kotlinFqName.asString()
        
        if (fqName == "com.minogin.confirm.api.lessThan") {
            // Logic for replacement goes here...
        }
        
        return super.visitCall(expression)
    }

    2. Creating the Replacement

    Once we’ve caught the lessThan call, we need to replace it with a constructor call to LessThanMatcher. This requires finding the class symbol in the classpath and mapping the original arguments.

    Kotlin
    // Setup the builder for the current scope
    val scopeSymbol = allScopes.lastOrNull()?.scope?.scopeOwnerSymbol 
        ?: return super.visitCall(expression)
        
    val builder = DeclarationIrBuilder(context, scopeSymbol, expression.startOffset, expression.endOffset)
    
    // Reference the implementation class (LessThanMatcher)
    val classId = ClassId(FqName("com.minogin.confirm.impl"), Name.identifier("LessThanMatcher"))
    val classSymbol = context.referenceClass(classId) ?: error("Implementation class not found")
    val constructorSymbol = classSymbol.owner.constructors.first().symbol
    
    // Rewrite the IR: lessThan(x) -> LessThanMatcher(x)
    return builder.irCall(constructorSymbol).apply {
        arguments[0] = expression.arguments[0] 
    }

    The “Magic” in the Bytecode

    Because this transformation happens at the IR level (after type checking but before JVM bytecode generation), the compiler is happy, and the runtime gets the object it needs.

    If we decompile the resulting .class file, we see that our placeholder function has vanished:

    Java
    // Original source
    confirmThat(10, () -> lessThan(20));
    
    // Decompiled output
    DSLKt.confirmThat(10, (scope) -> {
        return (Matcher)(new LessThanMatcher(20));
    });

    Summary & Best Practices

    By using IR Transformers, we’ve created a “syntax illusion”—providing a clean, type-safe API that behaves differently under the hood.

    A note for 2026: Since the K2 compiler is now standard, ensure your plugin is registered via the IrGenerationExtension. IR manipulation is powerful, but remember that you are bypassing standard language constraints; always provide clear error messages using IrMessageLogger if the transformation fails.


    Next time: We will dive into the project configuration (Gradle setup) and how to effectively debug your IR code.

  • Kotlin IR: Unlocking Incredible Possibilities for Code Manipulation

    I’m currently working on a pet project: a Kotlin assertion library designed to handle deep assertions over any object type. The goal is to write code that looks like this:

    Kotlin
    confirmThat { (1..3).toList() } deepMatches { listOf(1, 2, 3) }

    To make this work, the library needs to convert arbitrary code into a structured matcher tree. Under the hood, the goal is to transform that simple list check into something like this:

    Kotlin
    confirmThat { (1..3).toList() } deepMatches {
        ListMatcher(
            ValueMatcher(1),
            ValueMatcher(2),
            ValueMatcher(3)
        )
    }

    To pull this off, I weighed two sophisticated technical paths:

    Option 1: Runtime Bytecode Manipulation

    The first option is to modify the compiled bytecode while the application is running using tools like Byte Buddy.

    • The Pro: It’s a standard way to handle introspection on the JVM without needing a custom compiler setup.
    • The Con: It has very limited capabilities because Kotlin-specific details — like null-safety metadata — are often erased or transformed once the code is compiled. Many things are just impossible — e.g. you cannot convert primitives (int) into objects (Int), which might make it impossible to replace int with IntMatcher.

    Option 2: Kotlin Intermediate Representation (IR)

    The more “hardcore” approach is hijacking the Kotlin compilation process itself. By using a Kotlin Compiler Plugin, I can intercept the IR (Intermediate Representation). This happens after the code is parsed but before it undergoes any “lowering” steps (converting high-level constructs into simpler ones) or gets turned into bytecode.

    • The Pro: This allows me to see the code in its purest form. I can generate highly efficient, type-safe matchers that are baked directly into the program.
    • The Con: It’s significantly more complex to implement since it requires working deep within the compiler’s internal mechanics.

    My Take

    I decided to go the “hardcore” route. I’ve been experimenting with Kotlin IR, and it works perfectly! It handles the primitive-to-object mapping and null-safety metadata with ease.

    Stay tuned—I’ll be sharing more on how I actually implemented the IR transformer in my next post.

  • Reducing memory usage 10 times with High-Performance Primitive Collections

    Kotlin basic types such as Int or Double correspond to high-performance Java primitive types such as int or double. But nullable (Int?) and generic (<Int>) versions of those types are mapped to boxed Java types such as Integer or Double.

    Boxed types are memory heavy. Let’s make a simple comparison.

    Kotlin
    @Test
    fun `memory occupied by primitive int`() {
        data class A(
            val x: Int
        )
    
        val N = 100_000_000
    
        val mem1 = calculateOccupiedMemoryMB()
    
        val list = List(N) { A(it) }
    
        val mem2 = calculateOccupiedMemoryMB()
    
        println("Occupied memory: ${mem2 - mem1} MB")
    
        list
    }
    
    > Occupied memory: 1910 MB
    Kotlin
    @Test
    fun `memory occupied by boxed Int`() {
        data class A(
            val x: Int?
        )
    
        val N = 100_000_000
    
        val mem1 = calculateOccupiedMemoryMB()
    
        val list = List(N) { A(it) }
    
        val mem2 = calculateOccupiedMemoryMB()
    
        println("Occupied memory: ${mem2 - mem1} MB")
    
        list
    }
    
    > Occupied memory: 3436 MB

    We already see almost 2x difference, but actually it’s more serious as our test is not accurate enough.

    Code explained

    calculateOccupiedMemoryMB measures the diff between total and occupied memory running garbage collection for at least 3 seconds in advance to reduce the garbage footprint.

    Kotlin
    fun calculateOccupiedMemoryMB(): Int {
        getRuntime().gc()
        Thread.sleep(3000)
        return ((getRuntime().totalMemory() - getRuntime().freeMemory()) / (1024 * 1024)).toInt()
    }

    list reference at the end of the block is a trick to avoid JVM optimization. If JVM sees an object is not used it might wipe it off the RAM.

    What if we need a huge Set of Int‘s or a huge Map of Int to Object? Unfortunately standard Java Collections are based on generics which means all of the objects will be autoboxed.

    Here HPPC: High Performance Primitive Collections comes to the rescue. This library has predefined collection for all the primitive types.

    Let’s compare memory footprints of a normal Java HashSet<Int> and a corresponding HPPC IntHashSet.

    Kotlin
    @Test
    fun `memory occupied by HashSet`() {
        val N = 100_000_000
    
        val mem1 = calculateOccupiedMemoryMB()
    
        val set = hashSetOf<Int>()
        repeat(N) { set.add(it) }
    
        val mem2 = calculateOccupiedMemoryMB()
    
        println("Occupied memory: ${mem2 - mem1} MB")
    
        1 in set
    }
    
    > Occupied memory: 5098 MB
    Kotlin
    @Test
    fun `memory occupied by HashSet`() {
        val N = 100_000_000
    
        val mem1 = calculateOccupiedMemoryMB()
    
        val set = IntHashSet()
        repeat(N) { set.add(it) }
    
        val mem2 = calculateOccupiedMemoryMB()
    
        println("Occupied memory: ${mem2 - mem1} MB")
    
        1 in set
    }
    
    > Occupied memory: 518 MB

    10 times less memory used!