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.

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *