Posted by: David Rawson
What is KSP?
KSP (Kotlin Symbol Processor) is a new API from Google for writing Kotlin compiler plugins. Using KSP we can write annotation processors to reduce boilerplate, solve cross-cutting concerns, and move checks from runtime to compile-time.
While we already have annotation processors in Kotlin through kapt, the new KSP API provides promises speed and ergonomics.
Firstly, KSP is faster since kapt depends on compiling Kotlin to Java stubs for consumption by thejavax.lang.model
API used by Java annotation processors. Compiling these stubs takes a significant amount of time, and since KSP can skip this step we can write faster processors.
Secondly, KSP provides an idiomatic Kotlin API. Much of the old javax.lang.model
API is poorly suited for dealing with the idiosyncrasies of Kotlin. By contrast, KSP has a Kotlin-first approach, while still allowing processing of Java files.
Isn’t that very niche?
While this may seem like an extreme departure from standard annotation processor practice, things are heating up for KSP! At the time of writing, the popular annotation processors for Room and Moshi have experimental support, while Dagger, Hilt, and Glide have open issues tracking support. The goal is the complete elimination of kapt, which will lead to the biggest
performance gain:
Let’s get started
We’re going to start by building a toy annotation processor. Following this article by Lubos Mudrak, our annotation processor will emit an extension function for summing the Int
properties of a Kotlin data class. In other words, given the following data class:
@IntSummable | |
data class Foo( | |
val bar: Int = 234, | |
val baz: Int = 123 | |
) |
We would like to emit the following code:
public fun FooSummable.sumInts(): Int { | |
val sum = bar + baz | |
return sum | |
} |
Dependencies
We will start with a Kotlin JVM module with the following dependencies:
plugins { | |
kotlin("jvm") | |
id("com.google.devtools.ksp") version "1.5.31-1.0.0" | |
} | |
repositories { | |
mavenCentral() | |
google() | |
} | |
dependencies { | |
implementation(kotlin("stdlib")) | |
implementation("com.google.devtools.ksp:symbol-processing-api:1.5.31-1.0.0") | |
} |
Note that we need the google()
repository for KSP.

Provider
Now we can start writing our classes — the first is a provider used to instantiate our processor:
import com.google.devtools.ksp.symbol.* | |
class IntSummableProcessorProvider : SymbolProcessorProvider { | |
override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor { | |
return IntSummableProcessor( | |
options = environment.options, | |
codeGenerator = environment.codeGenerator, | |
logger = environment.logger | |
) | |
} | |
} |
In the create
function, you get a handle on the SymbolProcessorEnvironment
which will allow you to pass options, logging, and code generation as dependencies to your symbol processor.
class IntSummableProcessor( | |
private val options: Map<String, String>, | |
private val codeGenerator: CodeGenerator, | |
private val logger: KSPLogger | |
) : SymbolProcessor { | |
private lateinit var intType: KSType | |
override fun process(resolver: Resolver): List<KSAnnotated> { | |
intType = resolver.builtIns.intType | |
val symbols = resolver.getSymbolsWithAnnotation(IntSummable::class.qualifiedName!!) | |
val unableToProcess = symbols.filterNot { it.validate() } | |
symbols.filter { it is KSClassDeclaration && it.validate() } | |
.forEach { it.accept(Visitor(), Unit) } | |
return unableToProcess.toList() | |
} | |
} |
For our processor itself, we override a process
function where we get a Resolver
as a parameter. We use the resolver
to get the KSType
for Int
since we will be using this later. Then we look for annotations of the type IntSummable
and check them using one of KSP’s inbuilt functions KSNode#validate
. We will return a list of any invalid symbols at the end in order to fulfill the function contract. Finally, we will use the visitor pattern to visit each class declaration marked with our annotation.

Visitor
Visitors in KSP have a signature with two type parameters:
interface KSVisitor<D, R> { | |
fun visitNode(node: KSNode, data: D): R | |
fun visitAnnotated(annotated: KSAnnotated, data: D): R | |
// etc. | |
} |
The D
is an opportunity for you to pass some data into the visitor, while the R
is the result you want to return from visiting. The type parameters allow you to create pipelines where you chain the output of one visitor to the input of another visitor.
For our visitor we will keep things simple and perform side-effects on members rather than maintain purity, and so we will use KSVisitor<Unit, Unit>
. KSP provides a convenience class for this called KSVisitorVoid
:
inner class Visitor : KSVisitorVoid() { | |
override fun visitClassDeclaration(classDeclaration: KSClassDeclaration, data: Unit) { | |
val qualifiedName = classDeclaration.qualifiedName?.asString() | |
if (!classDeclaration.isDataClass()) { | |
logger.error( | |
"@IntSummable cannot target non-data class $qualifiedName", | |
classDeclaration | |
) | |
return | |
} | |
if (qualifiedName == null) { | |
logger.error( | |
"@IntSummable must target classes with qualified names", | |
classDeclaration | |
) | |
return | |
} | |
} | |
private fun KSClassDeclaration.isDataClass() = modifiers.contains(Modifier.DATA) | |
} |
The first part of our visitor performs some easy validation on the class declaration we are visiting, checking whether it is a data class and whether it has a qualified name.

Job Offers
Engine
Now we get to the engine of our visitor. Since we will need the class name, package name, and a list of summable properties in order to generate our code, we are going to store these in members:
inner class Visitor : KSVisitorVoid() { | |
private lateinit var className: String | |
private lateinit var packageName: String | |
private val summables: MutableList<String> = mutableListOf() | |
override fun visitClassDeclaration(classDeclaration: KSClassDeclaration, data: Unit) { | |
val qualifiedName = classDeclaration.qualifiedName?.asString() | |
className = qualifiedName | |
packageName = classDeclaration.packageName.asString() | |
classDeclaration.getAllProperties() | |
.forEach { | |
it.accept(this, Unit) | |
} | |
if (summables.isEmpty()) { | |
return | |
} | |
} | |
override fun visitPropertyDeclaration(property: KSPropertyDeclaration, data: Unit) { | |
if (property.type.resolve().isAssignableFrom(intType)) { | |
val name = property.simpleName.asString() | |
summables.add(name) | |
} | |
} | |
} |
We obtain the properties we want by having a property declaration visitor visit every property and checking if the type is assignable from Int
.

Code generation
We’ve now got enough information to emit our generated code. The KSP examples use string templates to construct the code, but we can bring in KotlinPoet to help us here:
dependencies { | |
implementation("com.squareup:kotlinpoet:1.10.1") | |
implementation("com.squareup:kotlinpoet-ksp:1.10.1") | |
} |
// continued from above | |
if (summables.isEmpty()) { | |
return | |
} | |
val fileSpec = FileSpec.builder( | |
packageName = packageName, | |
fileName = classDeclaration.simpleName.asString() + "Ext" | |
).apply { | |
addFunction( | |
FunSpec.builder("sumInts") | |
.receiver(ksType.toTypeName(TypeParameterResolver.EMPTY)) | |
.returns(Int::class) | |
.addStatement("val sum = %L", summables.joinToString(" + ")) | |
.addStatement("return sum") | |
.build() | |
) | |
}.build() | |
fileSpec.writeTo(codeGenerator = codeGenerator, aggregating = false) | |
} |
We will use FileSpec
and FunSpec
from KotlinPoet to build the function we need to emit. Finally, the CodeGenerator
provided by KSP can provide us an OutputStream
which plugs into KotlinPoet’s FileSpec#writeTo
.

Testing
The good news for testing is we can use our pre-existing tools:
dependencies { | |
testImplementation("com.github.tschuchortdev:kotlin-compile-testing:1.4.4") | |
testImplementation("com.github.tschuchortdev:kotlin-compile-testing-ksp:1.4.4") | |
} |
Thilo Schuchort’s excellent compile testing tool comes with extensions for testing KSP. This is shown in a private function from our test class below where we pass in a list of SymbolProcessorProvider
:
private fun compilation(vararg source: SourceFile) = KotlinCompilation().apply { | |
sources = source.toList() | |
symbolProcessorProviders = listOf(IntSummableProcessorProvider()) | |
workingDir = temporaryFolder.root | |
inheritClassPath = true | |
verbose = false | |
} |
Obtaining the output source files is a bit tricky at the moment. I used a technique described by Gabriel Freitas Vasconcelos in the Github issues:
private fun KotlinCompilation.generatedSourceFor(fileName: String): String { | |
return kspSourcesDir.walkTopDown() | |
.firstOrNull { it.name == fileName } | |
?.readText() | |
?: throw IllegalArgumentException( | |
"Unable to find $fileName in ${ | |
kspSourcesDir.walkTopDown().filter { it.isFile }.toList() | |
}" | |
) | |
} | |
private val KotlinCompilation.kspWorkingDir: File | |
get() = workingDir.resolve("ksp") | |
private val KotlinCompilation.kspSourcesDir: File | |
get() = kspWorkingDir.resolve("sources") |
Now we can write our tests:
@Test | |
fun `target is a data class`() { | |
val kotlinSource = SourceFile.kotlin( | |
"file1.kt", | |
""" | |
package com.tests.summable | |
import com.tsongkha.kspexample.annotation.IntSummable | |
@IntSummable | |
data class FooSummable( | |
val bar: Int = 234, | |
val baz: Int = 123 | |
) | |
""" | |
) | |
val compilation = compilation(kotlinSource) | |
val result = compilation.compile() | |
assertSourceEquals( | |
""" | |
package com.tests.summable | |
import kotlin.Int | |
public fun FooSummable.sumInts(): Int { | |
val sum = bar + baz | |
return sum | |
}""", | |
result.sourceFor("FooSummableExt.kt") | |
) | |
assertEquals(KotlinCompilation.ExitCode.OK, result.exitCode) | |
} |
The full sample is below:
drawers/ksp-sample |
Fact or cap?
We’ve seen a simple example using KSP — the performance enhancements are promising and it certainly embraces Kotlin as a first-class citizen. So is KSP fact or cap? Definitely “fact”!