Kotlin Annotation Processing
Introduction
Annotation processing is a powerful technique in modern programming languages that allows developers to generate code at compile time. In Kotlin, annotation processing provides a way to automate repetitive coding tasks, implement design patterns systematically, and extend your application's capabilities through metaprogramming.
This guide will walk you through understanding Kotlin annotations, setting up annotation processing, creating custom annotations, and implementing real-world applications of this advanced feature.
What are Annotations?
Annotations are special metadata markers that add context or instructions to code elements. They don't directly affect program execution but provide information to the compiler, IDE, or other tools.
In Kotlin, annotations look like this:
@Deprecated("Use newMethod() instead", ReplaceWith("newMethod()"))
fun oldMethod() {
// Function body
}
This @Deprecated
annotation tells the compiler and IDE that this function is no longer recommended for use, suggests a replacement, and can trigger warnings or errors when the function is called.
Kotlin Annotation Processing Tool (KAPT)
Kotlin doesn't have built-in annotation processing like Java does. Instead, it uses the Kotlin Annotation Processing Tool (KAPT) to work with Java's annotation processing system.
Setting up KAPT in your project
To use KAPT in a Gradle project:
- Add the KAPT plugin to your
build.gradle.kts
file:
plugins {
kotlin("jvm") version "1.8.0"
kotlin("kapt") version "1.8.0"
}
- Add any annotation processors you need as KAPT dependencies:
dependencies {
implementation("com.example:library:1.0.0")
kapt("com.example:library-processor:1.0.0")
}
Creating Custom Annotations
Let's create a simple annotation system that generates toString() implementations.
Step 1: Define the annotation
First, create an annotation class:
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
annotation class GenerateToString(
val includeFieldNames: Boolean = true
)
This annotation can be applied to classes and exists only at compile time (source retention).
Step 2: Create the annotation processor
You'll need to create a separate module or project for your annotation processor:
import com.google.auto.service.AutoService
import javax.annotation.processing.*
import javax.lang.model.SourceVersion
import javax.lang.model.element.TypeElement
import javax.lang.model.element.ElementKind
import javax.tools.Diagnostic
@AutoService(Processor::class)
class ToStringProcessor : AbstractProcessor() {
override fun getSupportedAnnotationTypes() = setOf(
GenerateToString::class.java.canonicalName
)
override fun getSupportedSourceVersion() = SourceVersion.latest()
override fun process(annotations: Set<TypeElement>, roundEnv: RoundEnvironment): Boolean {
val annotatedElements = roundEnv.getElementsAnnotatedWith(GenerateToString::class.java)
annotatedElements.forEach { element ->
if (element.kind != ElementKind.CLASS) {
processingEnv.messager.printMessage(
Diagnostic.Kind.ERROR,
"GenerateToString can only be applied to classes"
)
return true
}
val className = element.simpleName.toString()
val packageName = processingEnv.elementUtils.getPackageOf(element).toString()
// Generate toString implementation
// ...code generation logic here...
}
return true
}
}
Step 3: Use the annotation
Now you can use the annotation in your code:
@GenerateToString
data class Person(val name: String, val age: Int)
At compile time, the processor will generate a toString() implementation for the Person class.
Using Popular Annotation Processing Libraries
Several popular libraries use annotation processing to reduce boilerplate code. Let's explore some examples:
Room Database with KAPT
Room is an Android database library that extensively uses annotations:
- Add the dependencies:
dependencies {
implementation("androidx.room:room-runtime:2.5.0")
kapt("androidx.room:room-compiler:2.5.0")
}
- Create your database entities with annotations:
@Entity
data class User(
@PrimaryKey val id: Int,
val name: String,
val email: String
)
@Dao
interface UserDao {
@Query("SELECT * FROM user")
fun getAll(): List<User>
@Insert
fun insertUser(user: User)
}
@Database(entities = [User::class], version = 1)
abstract class AppDatabase : RoomDatabase() {
abstract fun userDao(): UserDao
}
Room's annotation processor generates all the boilerplate code needed for database operations, saving you from writing SQL and data mapping code.
Dependency Injection with Dagger
Dagger is a popular dependency injection framework that uses annotation processing:
@Module
class NetworkModule {
@Provides
fun provideApiService(): ApiService {
return Retrofit.Builder()
.baseUrl("https://api.example.com/")
.build()
.create(ApiService::class.java)
}
}
@Component(modules = [NetworkModule::class])
interface AppComponent {
fun inject(activity: MainActivity)
}
class MainActivity : AppCompatActivity() {
@Inject
lateinit var apiService: ApiService
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
DaggerAppComponent.create().inject(this)
// Now apiService is automatically initialized
}
}
Creating a Simple Annotation Processor Project
Let's build a complete, simple annotation processor that generates builder classes for data objects:
Project Structure
project/
├── builder-annotations/ # Contains annotation definitions
│ ├── build.gradle.kts
│ └── src/.../Builder.kt
├── builder-processor/ # Contains annotation processor
│ ├── build.gradle.kts
│ └── src/.../BuilderProcessor.kt
└── app/ # Main application using the annotations
├── build.gradle.kts
└── src/.../TestClass.kt
1. Annotation Module
Builder.kt
:
package com.example.builder.annotations
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
annotation class Builder
2. Processor Module
builder-processor/build.gradle.kts
:
plugins {
kotlin("jvm")
kotlin("kapt")
}
dependencies {
implementation(project(":builder-annotations"))
implementation("com.squareup:javapoet:1.13.0")
implementation("com.google.auto.service:auto-service:1.0.1")
kapt("com.google.auto.service:auto-service:1.0.1")
}
BuilderProcessor.kt
:
package com.example.builder.processor
import com.example.builder.annotations.Builder
import com.google.auto.service.AutoService
import com.squareup.javapoet.*
import javax.annotation.processing.*
import javax.lang.model.SourceVersion
import javax.lang.model.element.Modifier
import javax.lang.model.element.TypeElement
import javax.lang.model.util.ElementFilter
@AutoService(Processor::class)
class BuilderProcessor : AbstractProcessor() {
override fun getSupportedAnnotationTypes(): Set<String> {
return setOf(Builder::class.java.canonicalName)
}
override fun getSupportedSourceVersion(): SourceVersion {
return SourceVersion.latest()
}
override fun process(annotations: Set<TypeElement>, roundEnv: RoundEnvironment): Boolean {
roundEnv.getElementsAnnotatedWith(Builder::class.java)
.forEach { element ->
val typeElement = element as TypeElement
val className = typeElement.simpleName.toString()
val packageName = processingEnv.elementUtils.getPackageOf(element).toString()
val properties = ElementFilter.fieldsIn(element.enclosedElements).map {
PropertySpec(it.simpleName.toString(), TypeName.get(it.asType()))
}
generateBuilderClass(packageName, className, properties)
}
return true
}
private fun generateBuilderClass(packageName: String, className: String, properties: List<PropertySpec>) {
val builderClassName = "${className}Builder"
val builderClass = TypeSpec.classBuilder(builderClassName)
.addModifiers(Modifier.PUBLIC)
properties.forEach { property ->
// Add field
builderClass.addField(property.type, property.name, Modifier.PRIVATE)
// Add setter method
builderClass.addMethod(
MethodSpec.methodBuilder(property.name)
.addModifiers(Modifier.PUBLIC)
.returns(ClassName.get(packageName, builderClassName))
.addParameter(property.type, property.name)
.addStatement("this.\$L = \$L", property.name, property.name)
.addStatement("return this")
.build()
)
}
// Add build method
val buildMethod = MethodSpec.methodBuilder("build")
.addModifiers(Modifier.PUBLIC)
.returns(ClassName.get(packageName, className))
.addStatement("\$T object = new \$T()", ClassName.get(packageName, className), ClassName.get(packageName, className))
properties.forEach { property ->
buildMethod.addStatement("object.\$L = this.\$L", property.name, property.name)
}
buildMethod.addStatement("return object")
builderClass.addMethod(buildMethod.build())
// Write the class to a file
JavaFile.builder(packageName, builderClass.build())
.build()
.writeTo(processingEnv.filer)
}
data class PropertySpec(val name: String, val type: TypeName)
}
3. Main Module
app/build.gradle.kts
:
plugins {
kotlin("jvm")
kotlin("kapt")
}
dependencies {
implementation(project(":builder-annotations"))
kapt(project(":builder-processor"))
}
TestClass.kt
:
package com.example.app
import com.example.builder.annotations.Builder
@Builder
class User {
var name: String? = null
var email: String? = null
var age: Int = 0
}
fun main() {
val user = UserBuilder()
.name("John Doe")
.email("[email protected]")
.age(30)
.build()
println("Created user: ${user.name}, ${user.email}, ${user.age}")
}
When you compile this project, KAPT will run the BuilderProcessor, which will generate a UserBuilder class with fluent setter methods for each property.
Debugging Annotation Processors
Debugging annotation processors can be challenging since they run during compilation. Here are some tips:
- Use logging with the Messager API:
processingEnv.messager.printMessage(
Diagnostic.Kind.NOTE, // Or ERROR, WARNING
"Processing element: $element"
)
- Generate debug files:
val writer = processingEnv.filer
.createResource(StandardLocation.SOURCE_OUTPUT, "", "debug.txt")
.openWriter()
writer.write("Debug info: $debugInfo")
writer.close()
- Run the compiler with verbose output:
./gradlew clean build --info
Best Practices for Annotation Processing
-
Keep annotations simple: Annotations should be straightforward and collect only necessary information.
-
Handle errors gracefully: Provide clear error messages when annotation usage is incorrect.
-
Generate readable code: The code you generate should be clean and maintainable, just as if it were written by hand.
-
Avoid dependencies in generated code: Generated code should minimize dependencies on external libraries.
-
Document your annotations: Provide clear documentation on how to use your annotations and what they generate.
Summary
Kotlin annotation processing is a powerful technique that allows you to generate code at compile time, reducing boilerplate and enforcing patterns across your codebase. By using KAPT, you can:
- Create custom annotations that generate useful code
- Integrate with popular annotation-based libraries
- Build tools to enforce coding standards and design patterns
- Implement complex patterns with less manual code
While there's a learning curve to creating annotation processors, they can significantly enhance your productivity for large projects where similar patterns are frequently repeated.
Additional Resources
- Official Kotlin KAPT documentation
- Google's AutoService for processor registration
- JavaPoet for Java code generation
- KotlinPoet for Kotlin code generation
Exercises
- Create a simple
@Log
annotation that generates logging methods for a class. - Implement a
@Parcelable
annotation that generates Android Parcelable implementation. - Build an annotation processor for a
@JsonSerializable
annotation that generates JSON serialization code. - Create an
@EventBus
annotation that generates event subscription and publishing methods. - Enhance the Builder annotation example to validate required fields at compile time.
If you spot any mistakes on this website, please let me know at [email protected]. I’d greatly appreciate your feedback! :)