Understanding Kotlin Compiler Internals: From K1 to K2, FIR, IR, and Compose Compiler
This article explains the architecture and evolution of the Kotlin compiler, detailing the roles of the frontend and backend, the transition from K1’s PSI and BindingContext to K2’s FIR and IR, and how Kotlin Compiler Plugins like Compose and No‑arg are implemented.
Preface
Compose’s concise syntax and high code efficiency stem from a series of compile‑time tricks performed by the Compose Compiler, which generates a lot of boilerplate code. However, these compile‑time insertions obscure the runtime principles of Compose, so understanding Compose requires first learning about its compiler.
Compose is a Kotlin‑only framework, therefore the Compose Compiler is essentially a Kotlin Compiler Plugin (KCP). Before diving into the source code of the Compose Compiler, we need to lay some groundwork on the Kotlin compiler and KCP basics.
Kotlin Compilation Process
Kotlin is a cross‑platform language; the Kotlin compiler can emit code for several targets (JS, JVM bytecode, LLVM, etc.). Regardless of the target, the compilation can be divided into two stages:
Frontend : parses source code to produce an AST (abstract syntax tree) and a symbol table, and performs static checks.
Backend : consumes the frontend artifacts (AST, symbol table) and generates platform‑specific target code.
In short, the frontend parses and checks the source, while the backend generates the target code.
For Kotlin/JVM the process looks like this:
Frontend: the .kt source file undergoes lexical, syntactic, and semantic analysis (Lexer & Parser) to produce PSI and a corresponding BindingContext.
Backend: based on PSI and BindingContext, JVM bytecode is generated and then turned into a .class file by ASM.
The frontend processing is identical for all target platforms; only the backend differs to emit the appropriate target code.
K1 Compiler: PSI & BindingContext
PSI (Program Structure Interface) can be seen as JetBrains’ extended AST. PSI is used for static syntax checking during compilation and also powers the real‑time error highlighting in IntelliJ‑based IDEs. It is therefore useful for both compiler implementations and IDE plugins such as Detekt.
PSI: https://plugins.jetbrains.com/docs/intellij/psi-elements.html Detekt: https://github.com/detekt/detekt
In the IDE, the PsiViewer plugin can display the PSI tree for a given source. For example:
fun main() {
println("Hello, World!")
}The PSI tree consists of PsiElement nodes such as symbols, strings, etc. However, PSI lacks contextual semantic information, which is supplied by the BindingContext.
BindingContext acts as a symbol table that maps each PsiElement to a Descriptor after semantic analysis. Descriptors contain the semantic details needed at compile time (e.g., function parameters, modifiers, etc.).
BindingContext is essentially a nested map:
Map<Type, Map<key, Descriptor>The first map’s key is the PSI node type; the second map’s key is the concrete PsiElement , and the value is its corresponding Descriptor . For example, using KtFunction as the key retrieves a FunctionDescriptor , while KtCallExpression yields a ResolvedCall containing the called function’s descriptor and arguments.
K2 Compiler: FIR & IR
K2 introduces a new compilation pipeline that replaces the PSI/BindingContext backend with an IR (Intermediate Representation) backend. IR is platform‑agnostic and enables compile‑time optimizations (e.g., suspend‑function handling) to be shared across targets.
K2: https://blog.jetbrains.com/zh-hans/kotlin/2021/10/the-road-to-the-k2-compiler/
The IR backend was made default for Kotlin/JVM in 1.5 and for Kotlin/JS in 1.6. The IR is also a tree‑like structure, but its nodes carry richer contextual information (visibility, modality, return type, etc.) compared to PSI.
For the earlier "Hello World" example, the IR tree looks like this:
FUN name:main visibility:public modality:FINAL <> () returnType:kotlin.Unit
BLOCK_BODY
CALL 'public final fun println (message: kotlin.Any?): kotlin.Unit [inline] declared in kotlin.io.ConsoleKt' type=kotlin.Unit origin=null
message: CONST String type=kotlin.String value="Hello, World!"Besides the new IR backend, K2 also updates the frontend: FIR (Frontend IR) replaces PSI and BindingContext. Starting with Kotlin 1.7.0, FIR is available for developers.
In summary, K2’s main changes are the introduction of FIR as the frontend and IR as the backend.
Although both FIR and IR are tree structures, they differ in purpose and capabilities:
FIR
IR
Goal
Improves frontend static analysis performance by integrating PSI and BindingContext information.
Provides a platform‑independent representation for backend code generation, enabling cross‑platform reuse of language features.
Structure
Still an AST with added symbol information.
Beyond an AST, it carries rich contextual semantics (e.g., variable lifetimes, visibility).
Capability
Primarily serves the frontend; limited ability to transform the AST.
Offers a powerful code‑gen API that can add, remove, or modify nodes arbitrarily.
Kotlin Compiler Plugin (KCP)
KCP allows developers to hook into the Kotlin compilation pipeline and perform arbitrary compile‑time transformations. Many Kotlin language features (e.g., no‑arg, all‑open, kotlinx‑serialization) are implemented as KCPs.
Compared with KAPT, KCP has two main advantages:
KCP runs directly inside the Kotlin compiler, avoiding the extra pre‑compilation step required by KAPT, which results in better performance. KSP (Kotlin Symbol Processing) is also built on KCP, explaining its speed.
KAPT mainly generates new source code, while KCP can modify existing bytecode or IR, offering far greater flexibility.
KCP Development Steps
Creating a full‑featured KCP involves several steps:
Gradle Plugin Plugin : defines a Gradle plugin that supplies the required compiler arguments. Subplugin : bridges the Gradle plugin to the Kotlin compiler, passing configuration parameters.
Kotlin Plugin CommandLineProcessor : entry point that defines the KCP ID and parses command‑line options. ComponentRegister : registers the extension points; requires an auto‑service annotation. XXExtension : implements the actual transformation logic (e.g., ExpressionCodegenExtension, ClassBuilderInterceptorExtension).
When Kotlin migrates from K1 to K2, KCP also provides K2‑specific extensions.
No‑arg Plugin Example
No‑arg generates a parameter‑less constructor for annotated classes. The source contains two sets of extensions to support both K1 and K2:
No‑arg: https://kotlinlang.org/docs/no-arg-plugin.html source: https://cs.android.com/android-studio/kotlin/+/master:plugins/noarg/
NoArg K1 CliNoArgDeclarationChecker : checks that the class is not an inner class using PSI. CliNoArgExpressionCodegenExtension : extends ExpressionCodegenExtension to emit a no‑arg constructor bytecode based on PSI and descriptors.
NoArg K2 FirNoArgDeclarationChecker : FIR‑based check for inner classes. NoArgIrGenerationExtension : extends IrGenerationExtension to add a no‑arg constructor using the IR API.
Backend Extension Comparison
Implementation differences are illustrated by the following snippets.
// 1. Obtain class information from the descriptor
val superClassInternalName = typeMapper.mapClass(descriptor.getSuperClassOrAny()).internalName
val constructorDescriptor = createNoArgConstructorDescriptor(descriptor)
val superClass = descriptor.getSuperClassOrAny()
// 2. Generate the no‑arg constructor bytecode directly via Codegen
functionCodegen.generateMethod(JvmDeclarationOrigin.NO_ORIGIN, constructorDescriptor, object : CodegenBased(state) {
override fun doGenerateBody(codegen: ExpressionCodegen, signature: JvmMethodSignature) {
codegen.v.load(0, AsmTypes.OBJECT_TYPE)
if (isParentASealedClassWithDefaultConstructor) {
codegen.v.aconst(null)
codegen.v.visitMethodInsn(Opcodes.INVOKESPECIAL, superClassInternalName, "
", "(Lkotlin/jvm/internal/DefaultConstructorMarker;)V", false)
} else {
codegen.v.visitMethodInsn(Opcodes.INVOKESPECIAL, superClassInternalName, "
", "()V", false)
}
if (invokeInitializers) {
generateInitializers(codegen)
}
codegen.v.visitInsn(Opcodes.RETURN)
}
}) // 1. Obtain class information from IrClass
val superClass = klass.superTypes.mapNotNull(IrType::getClass).singleOrNull { it.kind == ClassKind.CLASS } ?: context.irBuiltIns.anyClass.owner
val superConstructor = if (needsNoargConstructor(superClass))
getOrGenerateNoArgConstructor(superClass)
else
superClass.constructors.singleOrNull { it.isZeroParameterConstructor() }
?: error("No noarg super constructor for ${klass.render()}:\n" + superClass.constructors.joinToString("\n") { it.render() })
// 2. Build the constructor using IR APIs
context.irFactory.buildConstructor {
startOffset = SYNTHETIC_OFFSET
endOffset = SYNTHETIC_OFFSET
returnType = klass.defaultType
}.also { ctor ->
ctor.parent = klass
ctor.body = context.irFactory.createBlockBody(
ctor.startOffset, ctor.endOffset,
listOfNotNull(
IrDelegatingConstructorCallImpl(
ctor.startOffset, ctor.endOffset, context.irBuiltIns.unitType,
superConstructor.symbol, 0, superConstructor.valueParameters.size
),
IrInstanceInitializerCallImpl(
ctor.startOffset, ctor.endOffset, klass.symbol, context.irBuiltIns.unitType
).takeIf { invokeInitializers }
)
)
}The K2 version operates purely on IR, eliminating direct bytecode manipulation.
Compose Compiler
The Compose Compiler itself is a KCP. After understanding the basic KCP structure, we can see that its core lies in a set of extensions registered by ComposeComponentRegistrar :
class ComposeComponentRegistrar : ComponentRegistrar {
// ...
StorageComponentContainerContributor.registerExtension(project, ComposableCallChecker())
StorageComponentContainerContributor.registerExtension(project, ComposableDeclarationChecker())
StorageComponentContainerContributor.registerExtension(project, ComposableTargetChecker())
ComposeDiagnosticSuppressor.registerExtension(project, ComposeDiagnosticSuppressor())
@Suppress("OPT_IN_USAGE_ERROR")
TypeResolutionInterceptor.registerExtension(project, ComposeTypeResolutionInterceptorExtension())
IrGenerationExtension.registerExtension(project, ComposeIrGenerationExtension(
configuration = configuration,
liveLiteralsEnabled = liveLiteralsEnabled,
// other flags omitted for brevity
))
DescriptorSerializerPlugin.registerExtension(project, ClassStabilityFieldSerializationPlugin())
// ...
}ComposableCallChecker : verifies that a call to a @Composable function is valid.
ComposableDeclarationChecker : ensures @Composable annotations are placed correctly.
ComposeDiagnosticSuppressor : suppresses unnecessary compiler diagnostics.
ComposeIrGenerationExtension : the backend extension responsible for generating code for @Composable functions (the core of Compose code generation).
ClassStabilityFieldSerializationPlugin : analyses class stability and adds stability metadata.
All the checkers are frontend extensions still based on the K1 pipeline, while ComposeIrGenerationExtension targets K2’s IR backend, which is why Compose requires Kotlin ≥ 1.5.10.
References
Writing Your First Kotlin Compiler Plugin – https://resources.jetbrains.com/storage/products/kotlinconf2018/slides/5_Writing%20Your%20First%20Kotlin%20Compiler%20Plugin.pdf
Kotlin Compiler Internals In 1.4 and beyond – https://docs.google.com/presentation/d/e/2PACX-1vTzajwYJfmUi_Nn2nJBULi9bszNmjbO3c8K8dHRnK7vgz3AELunB6J7sfBodC2sKoaKAHibgEt_XjaQ/pub?slide=id.g955e8c1462_0_190
Rare Earth Juejin Tech Community
Juejin, a tech community that helps developers grow.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.