Table of Contents
- Introduction to Annotations
- Types of Custom Annotations
- Creating Your First Custom Annotation
- Annotation Retention Policies
- Annotation Targets
- Adding Parameters to Annotations
- Processing Custom Annotations
- Practical Example: Building a Debug Logger
- Advanced Use Cases
- Conclusion
- References
Introduction to Annotations
At their core, annotations are metadata—data that describes other data. They don’t directly affect the execution of code but provide context to tools, compilers, or runtime environments. For example:
@Override(Java) tells the compiler a method is intended to override a superclass method.@Test(JUnit) marks a function as a test case for the test runner.@SerializedName(Gson) specifies the JSON key for a property during serialization.
Custom annotations extend this functionality by letting you define metadata tailored to your needs. Whether you want to flag functions for logging, validate data models, or generate boilerplate code, custom annotations are the tool for the job.
Types of Custom Annotations
Custom annotations in Kotlin can be categorized by their structure and usage:
1. Marker Annotations
Simple annotations with no parameters. They act as “tags” to mark code elements.
Example: @Deprecated (though built-in, it’s a marker for obsolete code).
2. Single-Parameter Annotations
Annotations with a single parameter (often named value for conciseness).
Example: @Suppress("UNCHECKED_CAST") (suppresses a specific warning).
3. Multi-Parameter Annotations
Annotations with multiple named parameters, allowing richer metadata.
Example: @JsonSerializable(explicitNulls = true, ignoreUnknown = false).
Creating Your First Custom Annotation
Defining a custom annotation in Kotlin is straightforward. Use the annotation class keyword, followed by the annotation name. Let’s start with a simple marker annotation.
Example 1: Marker Annotation
// Define a marker annotation
annotation class DebugLog
That’s it! You’ve created a custom annotation. Now apply it to a function, class, or property:
// Apply @DebugLog to a function
@DebugLog
fun greet(name: String): String {
return "Hello, $name!"
}
At this point, @DebugLog is just a tag. To make it useful, we need to configure retention (how long the annotation persists) and targets (where it can be applied), then process it.
Annotation Retention Policies
Annotations are not retained by default beyond the source code. To control their lifecycle, use the @Retention annotation with one of three policies:
| Policy | Description | Use Case |
|---|---|---|
AnnotationRetention.SOURCE | Retained only in source code; discarded during compilation. | Code linting, IDE hints. |
AnnotationRetention.BINARY | Retained in the compiled class file but not accessible at runtime. | Compiler plugins, low-level optimizations. |
AnnotationRetention.RUNTIME | Retained in the class file and accessible at runtime via reflection. | Runtime processing (e.g., logging, validation). |
Setting Retention
Use @Retention on your annotation class to specify the policy:
import kotlin.annotation.AnnotationRetention
@Retention(AnnotationRetention.RUNTIME) // Retain at runtime
annotation class DebugLog
Now @DebugLog will be accessible via reflection at runtime.
Annotation Targets
By default, annotations can be applied to most code elements (classes, functions, properties, etc.). Use the @Target annotation to restrict where your annotation can be applied.
Common AnnotationTarget values:
CLASS: Classes, interfaces, objects, or enum classes.FUNCTION: Functions or constructors.PROPERTY: Properties (fields).VALUE_PARAMETER: Function or constructor parameters.TYPE: Type declarations (e.g.,List<@NonNull String>).
Example: Restrict @DebugLog to Functions
import kotlin.annotation.AnnotationRetention
import kotlin.annotation.AnnotationTarget
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FUNCTION) // Only apply to functions
annotation class DebugLog
Now @DebugLog can only be applied to functions (applying it to a class will throw a compile error).
Adding Parameters to Annotations
Annotations become more powerful with parameters. Parameters must be compile-time constants (e.g., primitives, strings, enums, other annotations, or arrays of these).
Example 1: Single-Parameter Annotation
Define an annotation with a value parameter (convention for single-parameter annotations):
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FUNCTION)
annotation class LogLevel(val value: String)
Apply it omitting the parameter name (since it’s value):
@LogLevel("INFO")
fun fetchData() { /* ... */ }
Example 2: Multi-Parameter Annotation
Add multiple parameters with default values for flexibility:
enum class LogSeverity { INFO, WARN, ERROR }
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FUNCTION)
annotation class LogDetails(
val severity: LogSeverity = LogSeverity.INFO, // Default: INFO
val includeParameters: Boolean = true,
val tag: String = "AppLog"
)
Apply it with named parameters:
@LogDetails(severity = LogSeverity.WARN, includeParameters = false)
fun deleteFile(path: String) { /* ... */ }
Processing Custom Annotations
Annotations are useless without processing. You’ll need to write code to read the annotation metadata and act on it. There are two primary processing strategies:
Runtime Processing with Reflection
Use Kotlin’s reflection API to access annotations at runtime. This is simple but has performance overhead (avoid in performance-critical paths).
Step 1: Add Reflection Dependency
For Kotlin reflection, add kotlin-reflect to your build.gradle (or build.gradle.kts):
dependencies {
implementation("org.jetbrains.kotlin:kotlin-reflect:1.9.0") // Use your Kotlin version
}
Step 2: Process Annotations via Reflection
Let’s build a processor for @DebugLog that logs function entry/exit.
import kotlin.reflect.KFunction
import kotlin.reflect.full.findAnnotation
import kotlin.reflect.full.valueParameters
// Function to process @DebugLog annotations
fun processDebugLog(function: KFunction<*>, args: Array<Any?>): Any? {
val annotation = function.findAnnotation<DebugLog>()
if (annotation != null) {
// Log entry with function name and parameters
val params = function.valueParameters.zip(args).joinToString { (param, value) ->
"${param.name}=${value}"
}
println("Entering ${function.name}($params)")
// Execute the function
val result = function.call(*args)
// Log exit with return value
println("Exiting ${function.name}, returned: $result")
return result
}
// No annotation? Execute normally
return function.call(*args)
}
Usage
Wrap function calls with processDebugLog:
@DebugLog
fun greet(name: String): String {
return "Hello, $name!"
}
fun main() {
// Get the KFunction for greet
val greetFunction = ::greet
// Call greet with processing
processDebugLog(greetFunction, arrayOf("Alice"))
// Output:
// Entering greet(name=Alice)
// Exiting greet, returned: Hello, Alice!
}
Compile-Time Processing with KAPT/KSP
For performance-critical or code-generation tasks, process annotations at compile time using:
- KAPT (Kotlin Annotation Processing Tool): Legacy tool, compatible with Java annotation processors.
- KSP (Kotlin Symbol Processing): Modern, faster alternative optimized for Kotlin.
KAPT/KSP generate code during compilation (e.g., Dagger generates dependency injectors, Room generates database adapters).
Example: KSP Code Generation
Suppose we want to generate a logger class for @DebugLog-annotated functions. Here’s a high-level workflow:
- Define
@DebugLogwithSOURCEretention (since we only need it during compilation). - Create a KSP processor that scans for
@DebugLogand generates aDebugLogger.ktfile with logging logic.
KSP is complex, so we’ll focus on the key steps. For a full tutorial, see the KSP docs.
Practical Example: Building a Debug Logger
Let’s combine everything to build a @DebugLog annotation that automatically logs function calls, parameters, and return values at runtime.
Step 1: Define the Annotation
import kotlin.annotation.AnnotationRetention
import kotlin.annotation.AnnotationTarget
@Retention(AnnotationRetention.RUNTIME)
@Target(AnnotationTarget.FUNCTION)
annotation class DebugLog
Step 2: Create a Reflection Processor
import kotlin.reflect.KFunction
import kotlin.reflect.full.findAnnotation
import kotlin.reflect.full.valueParameters
object DebugLogger {
fun <T> log(function: KFunction<T>, vararg args: Any?): T {
val annotation = function.findAnnotation<DebugLog>()
if (annotation != null) {
// Log entry
val params = function.valueParameters.zip(args)
.joinToString { (param, value) -> "${param.name}=${value}" }
println("[DEBUG] Calling ${function.name}($params)")
// Execute function and measure time
val startTime = System.currentTimeMillis()
val result = function.call(*args)
val duration = System.currentTimeMillis() - startTime
// Log exit and duration
println("[DEBUG] ${function.name} returned: $result (took $duration ms)")
return result
}
return function.call(*args)
}
}
Step 3: Use the Logger
@DebugLog
fun add(a: Int, b: Int): Int {
return a + b
}
@DebugLog
fun fetchUser(id: String): String {
Thread.sleep(100) // Simulate network delay
return "User $id"
}
fun main() {
DebugLogger.log(::add, 2, 3) // [DEBUG] Calling add(a=2, b=3) → returned: 5 (took 0 ms)
DebugLogger.log(::fetchUser, "123") // [DEBUG] Calling fetchUser(id=123) → returned: User 123 (took 100 ms)
}
Output:
[DEBUG] Calling add(a=2, b=3)
[DEBUG] add returned: 5 (took 0 ms)
[DEBUG] Calling fetchUser(id=123)
[DEBUG] fetchUser returned: User 123 (took 100 ms)
Advanced Use Cases
1. Nested Annotations
Annotations can contain other annotations as parameters:
annotation class AuthRequired(val roles: Array<Role>)
annotation class Role(val name: String)
@AuthRequired(roles = [Role("ADMIN"), Role("MODERATOR")])
fun deleteAccount() { /* ... */ }
2. Property Annotations with Targets
Kotlin properties have backing fields, getters, and setters. Use @get:, @set:, or @field: to target specific parts:
class User(
@get:Email // Apply to the getter
val email: String,
@set:Password // Apply to the setter
var password: String
)
3. Code Generation with KSP
Generate boilerplate code (e.g., Parcelable implementations for data classes):
@Parcelize // Built-in, but demonstrates code generation
data class User(val id: String, val name: String) : Parcelable
Conclusion
Custom annotations in Kotlin are a versatile tool for adding metadata-driven logic to your code. By defining annotations with specific retention and targets, adding parameters, and processing them at runtime (via reflection) or compile time (via KAPT/KSP), you can automate logging, validation, code generation, and more.
Key takeaways:
- Use
annotation classto define custom annotations. - Control retention with
@Retention(SOURCE/BINARY/RUNTIME). - Restrict targets with
@Target(FUNCTION/CLASS/PROPERTY/etc.). - Add parameters for rich metadata (primitives, strings, enums, arrays).
- Process annotations via reflection (runtime) or KAPT/KSP (compile time).
Start small with marker annotations, then experiment with parameters and processing to unlock powerful workflows!