cyberangles guide

Custom Annotations in Kotlin: A Tutorial

Annotations are a powerful feature in Kotlin (and Java) that allow you to attach metadata to code elements such as classes, functions, properties, or parameters. This metadata can be processed at compile time or runtime to enable dynamic behavior, code generation, or documentation. While Kotlin provides a rich set of built-in annotations (e.g., `@JvmStatic`, `@Serializable`, `@Test`), creating custom annotations unlocks endless possibilities for domain-specific logic, such as automated logging, validation, or code generation. In this tutorial, we’ll dive deep into custom annotations in Kotlin. You’ll learn how to define, configure, and process annotations, with practical examples to solidify your understanding. By the end, you’ll be able to leverage custom annotations to write cleaner, more maintainable, and metadata-driven code.

Table of Contents

  1. Introduction to Annotations
  2. Types of Custom Annotations
  3. Creating Your First Custom Annotation
  4. Annotation Retention Policies
  5. Annotation Targets
  6. Adding Parameters to Annotations
  7. Processing Custom Annotations
  8. Practical Example: Building a Debug Logger
  9. Advanced Use Cases
  10. Conclusion
  11. 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:

PolicyDescriptionUse Case
AnnotationRetention.SOURCERetained only in source code; discarded during compilation.Code linting, IDE hints.
AnnotationRetention.BINARYRetained in the compiled class file but not accessible at runtime.Compiler plugins, low-level optimizations.
AnnotationRetention.RUNTIMERetained 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:

  1. Define @DebugLog with SOURCE retention (since we only need it during compilation).
  2. Create a KSP processor that scans for @DebugLog and generates a DebugLogger.kt file 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 class to 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!

References