Skip to content

Metaprogramming

Reflection allows inspection of classes, functions, properties, and annotations at runtime. Kotlin Reflection requires an additional dependency.

dependencies {
implementation("org.jetbrains.kotlin:kotlin-reflect:2.1.0")
}
val kClass: KClass<String> = String::class
val jClass: Class<String> = String::class.java
val runtimeClass: KClass<out String> = "hello"::class
data class User(val name: String, var age: Int)
val nameProperty = User::name
println(nameProperty.name) // "name"
println(nameProperty.get(User("A", 1))) // "A"
val ageProperty = User::age
val user = User("A", 1)
ageProperty.set(user, 25)
fun isEven(n: Int): Boolean = n % 2 == 0
val predicate: (Int) -> Boolean = ::isEven
listOf(1, 2, 3, 4).filter(::isEven) // [2, 4]
val user = User("Alice", 30)
val nameGetter: () -> String = user::name
val ageSetter: (Int) -> Unit = user::age::set
data class Config(val host: String, val port: Int, val debug: Boolean)
val kClass = Config::class
kClass.memberProperties.forEach { prop ->
println("${prop.name}: ${prop.returnType}")
}
kClass.primaryConstructor?.parameters?.forEach { param ->
println("${param.name}: ${param.type}")
}
fun setProperty(obj: Any, propertyName: String, value: Any?) {
val prop = obj::class.memberProperties.firstOrNull { it.name == propertyName }
(prop as? KMutableProperty1<Any, Any?>)?.set(obj, value)
}
val config = Config("localhost", 8080, false)
setProperty(config, "debug", true)

Reflection has significant performance overhead compared to direct access. Use it for framework code (serialization, dependency injection, ORM) where the structure is not known at compile time.

Annotations attach metadata to declarations. They do not directly affect program behavior but can be Read via reflection or used by the compiler.

annotation class ApiEndpoint(val method: String, val path: String)
@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION)
@Retention(AnnotationRetention.RUNTIME)
@MustBeDocumented
annotation class Deprecated(val message: String, val replaceWith: String = "")
TargetApplies To
AnnotationTarget.CLASSClasses, interfaces, objects, annotations
AnnotationTarget.FUNCTIONFunctions (including constructors)
AnnotationTarget.PROPERTYProperties
AnnotationTarget.VALUE_PARAMETERFunction/constructor parameters
AnnotationTarget.FIELDBacking fields
AnnotationTarget.TYPETypes in type usages
RetentionBehavior
SOURCEDiscarded by the compiler (not in bytecode)
BINARYStored in bytecode but not visible via reflection
RUNTIMEStored in bytecode and accessible via reflection
@ApiEndpoint(method = "GET", path = "/users/{id}")
fun getUser(@Path("id") id: Long): User {
// ...
}

Annotation parameters are restricted to: primitive types, strings, classes, enums, other Annotations, and arrays of these types.

annotation class Validate(
val min: Int = 0,
val max: Int = Int.MAX_VALUE,
val message: String,
val groups: Array<KClass<*>> = []
)
@Validate(min = 1, max = 100, message = "Age must be between 1 and 100")
var age: Int = 0
@Target(AnnotationTarget.ANNOTATION_CLASS)
annotation class Validator
@Validator
annotation class MinLength(val value: Int)

Kotlin 1.5+ supports sealed interfaces, enabling closed type hierarchies without requiring a common Base class.

sealed interface Message {
val timestamp: Long
}
data class TextMessage(
override val timestamp: Long,
val content: String
) : Message
data class ImageMessage(
override val timestamp: Long,
val url: String,
val altText: String?
) : Message
data class SystemMessage(
override val timestamp: Long,
val event: String
) : Message

Sealed interfaces enable exhaustive when expressions, just like sealed classes.

fun render(message: Message): String = when (message) {
is TextMessage -> "[${message.timestamp}] ${message.content}"
is ImageMessage -> "[${message.timestamp}] Image: ${message.altText ?: message.url}"
is SystemMessage -> "[${message.timestamp}] System: ${message.event}"
}

A type can both extend a sealed class and implement a sealed interface:

sealed class Result<out T>
sealed interface Succeeded
data class Success<T>(val value: T) : Result<T>(), Succeeded

Value classes provide a way to wrap a value with a distinct type without the runtime overhead of Object allocation.

@JvmInline
value class UserId(val value: Long)
@JvmInline
value class Email(val value: String) {
init {
require(value.contains("@")) { "Invalid email" }
}
val domain: String
get() = value.substringAfter("@")
}

At runtime, UserId is represented as a plain Long. No object allocation occurs.

  • Must have exactly one property in the primary constructor.
  • Cannot have init blocks that access the backing property before it is initialized (validation in init blocks is allowed).
  • Cannot extend other classes (but can implement interfaces).
  • Cannot be used as generic type arguments at runtime (they are erased).
interface Identifiable {
val id: String
}
@JvmInline
value class OrderId(val value: String) : Identifiable {
override val id: String get() = value
}

Value classes use structural equality based on the wrapped value.

val a = UserId(42)
val b = UserId(42)
println(a == b) // true
println(a === b) // false (identity check not meaningful for value classes)

Kotlin allows overloading a fixed set of operators by defining functions with specific names.

data class Vec2(val x: Double, val y: Double) {
operator fun plus(other: Vec2) = Vec2(x + other.x, y + other.y)
operator fun minus(other: Vec2) = Vec2(x - other.x, y - other.y)
operator fun times(scalar: Double) = Vec2(x * scalar, y * scalar)
operator fun unaryMinus() = Vec2(-x, -y)
}
val a = Vec2(1.0, 2.0)
val b = Vec2(3.0, 4.0)
val c = a + b // Vec2(4.0, 6.0)
val d = -a // Vec2(-1.0, -2.0)
data class Version(val major: Int, val minor: Int, val patch: Int) : Comparable<Version> {
override fun compareTo(other: Version): Int = compareValuesBy(
this, other, { it.major }, { it.minor }, { it.patch }
)
}
val v1 = Version(2, 1, 0)
val v2 = Version(2, 3, 0)
println(v1 < v2) // true
class Matrix(private val data: DoubleArray, val rows: Int, val cols: Int) {
operator fun get(row: Int, col: Int): Double = data[row * cols + col]
operator fun set(row: Int, col: Int, value: Double) {
data[row * cols + col] = value
}
}
val m = Matrix(DoubleArray(9), 3, 3)
m[0, 0] = 1.0
println(m[0, 0]) // 1.0
operator fun Version.rangeTo(other: Version): ClosedRange<Version> {
return VersionRange(this, other)
}
for (v in Version(1, 0, 0)..Version(2, 0, 0)) {
println(v)
}
class Config {
operator fun invoke(block: Config.() -> Unit): Config {
this.block()
return this
}
}
val config = Config().invoke {
// configure
}
ExpressionOperator FunctionTranslation
a + ba.plus(b)Binary plus
a - ba.minus(b)Binary minus
a * ba.times(b)Multiplication
a / ba.div(b)Division
a % ba.rem(b)Remainder
-aa.unaryMinus()Unary minus
++a / a++a.inc()Increment
a > ba.compareTo(b) > 0Comparison
a in bb.contains(a)Contains
a[i]a.get(i)Indexing
a(i)a.invoke(i)Function call
a..ba.rangeTo(b)Range
  • ** Overusing reflection for tasks that can be solved with compile-time mechanisms. Reflection bypasses the type system and has significant performance cost. Prefer code generation, annotation processors, or inline functions.
  • ** Using value classes where inheritance is needed. Value classes cannot extend other classes. If you need a type hierarchy, use a regular class or sealed class.
  • ** Overloading operators in ways that violate the principle of least surprise. The + operator should represent addition or concatenation, not arbitrary behavior.
  • ** Forgetting that equals() and hashCode() are not generated for value classes implementing interfaces that declare them. The interface equality is used instead of structural equality.
  • ** Using reflection on value classes. Value classes are represented as their underlying type at runtime. KClass for a value class may not behave as expected — use the underlying type for reflection operations.

This topic covers the core concepts of metaprogramming, including underlying theory, practical implementation, and key applications.

Key concepts include:

  • variables, data types, and control flow
  • functions and procedures
  • object-oriented programming
  • error handling and debugging
  • modular design

Understanding these concepts thoroughly is essential for both examinations and practical programming, and requires both theoretical knowledge and hands-on practice.

Reflection is like having X-ray vision for your code — it lets you inspect the internal structure of classes, methods, and properties at runtime. Annotations are like labels you stick on your code, and reflection is the ability to read those labels and act on them. This is how frameworks like Spring and Hibernate work: they scan your classes for annotations and automatically wire up dependency injection or ORM mapping. The trade-off is performance: reflection is slower than direct access because the JVM must look up metadata dynamically. Use it for framework code where flexibility matters, and direct calls for performance-critical paths.

Worked examples demonstrating the application of key concepts are covered in the detailed sub-pages linked above.