Skip to main content

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:

kotlin
@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:

  1. Add the KAPT plugin to your build.gradle.kts file:
kotlin
plugins {
kotlin("jvm") version "1.8.0"
kotlin("kapt") version "1.8.0"
}
  1. Add any annotation processors you need as KAPT dependencies:
kotlin
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:

kotlin
@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:

kotlin
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:

kotlin
@GenerateToString
data class Person(val name: String, val age: Int)

At compile time, the processor will generate a toString() implementation for the Person class.

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:

  1. Add the dependencies:
kotlin
dependencies {
implementation("androidx.room:room-runtime:2.5.0")
kapt("androidx.room:room-compiler:2.5.0")
}
  1. Create your database entities with annotations:
kotlin
@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:

kotlin
@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:

kotlin
package com.example.builder.annotations

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.SOURCE)
annotation class Builder

2. Processor Module

builder-processor/build.gradle.kts:

kotlin
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:

kotlin
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:

kotlin
plugins {
kotlin("jvm")
kotlin("kapt")
}

dependencies {
implementation(project(":builder-annotations"))
kapt(project(":builder-processor"))
}

TestClass.kt:

kotlin
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:

  1. Use logging with the Messager API:
kotlin
processingEnv.messager.printMessage(
Diagnostic.Kind.NOTE, // Or ERROR, WARNING
"Processing element: $element"
)
  1. Generate debug files:
kotlin
val writer = processingEnv.filer
.createResource(StandardLocation.SOURCE_OUTPUT, "", "debug.txt")
.openWriter()
writer.write("Debug info: $debugInfo")
writer.close()
  1. Run the compiler with verbose output:
bash
./gradlew clean build --info

Best Practices for Annotation Processing

  1. Keep annotations simple: Annotations should be straightforward and collect only necessary information.

  2. Handle errors gracefully: Provide clear error messages when annotation usage is incorrect.

  3. Generate readable code: The code you generate should be clean and maintainable, just as if it were written by hand.

  4. Avoid dependencies in generated code: Generated code should minimize dependencies on external libraries.

  5. 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

Exercises

  1. Create a simple @Log annotation that generates logging methods for a class.
  2. Implement a @Parcelable annotation that generates Android Parcelable implementation.
  3. Build an annotation processor for a @JsonSerializable annotation that generates JSON serialization code.
  4. Create an @EventBus annotation that generates event subscription and publishing methods.
  5. 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! :)