Annotations and Reflection

Kotlin

2026-08-21 09:00

Where we are

Dropping an assumption

Every technique so far assumed the compiler knows what you are working with.

Generics stretched that. This unit drops it.

The problem

You are writing a JSON serialization library.

You cannot know your users’ classes — they have not been written.

What you need

To inspect an arbitrary object at runtime and find its properties.

And a way for the user to say “call this one first_name

without you knowing anything about their class.

The two mechanisms

Annotations — metadata attached to a declaration.

Reflection — reading a class’s structure at runtime.

If you have used a test framework or an ORM, you have used both from outside.

What you will be able to do

  1. Apply annotations with arguments.
  2. Use use-site targets.
  3. Declare your own annotations.
  4. Use @Target and @Retention.
  5. Pass classes as annotation parameters.
  6. Navigate the reflection API.
  7. Serialize an object by reflection.
  8. Explain callBy.
  9. Judge when reflection is the right tool.

Keep one thing in mind

Reflection defers to runtime what the compiler would otherwise check.

That is the whole power and the whole cost.

Applying annotations

The syntax

@Test fun testTrue() { }

@Deprecated("Use removeAt(index) instead.", ReplaceWith("removeAt(index)"))
fun remove(index: Int) { }

Kotlin’s @Deprecated carries a ReplaceWith pattern —

so the IDE can offer an automatic fix. Metadata doing real work.

Permitted argument types

Primitives · strings · enums · class references

other annotations · arrays of these

Compile-time constants only

const val TEST_TIMEOUT = 100L

@Test(timeout = TEST_TIMEOUT) fun testMethod() { }

An ordinary val is not enough — the value is written into the class file.

Annotation targets

The ambiguity

One property, several elements

A backing field. A getter. Sometimes a setter.

Sometimes a constructor parameter.

A Java library expecting the annotation on the field will not find one on the getter.

Use-site targets

class HasTempFolder {
    @get:Rule
    val folder = TemporaryFolder()
}

property · field · get · set · param · setparam · receiver · delegate · file

The debugging rule

Most annotation problems with Java libraries are target problems.

When an annotation seems ignored,

check the target first.

Declaring your own

annotation class

annotation class JsonExclude
annotation class JsonName(val name: String)

Parameters are val constructor properties.

No body — an annotation carries data and nothing else.

What it does

Meta-annotations

@Target(AnnotationTarget.PROPERTY)
annotation class JsonExclude

@Retention(AnnotationRetention.RUNTIME)
annotation class JsonName(val name: String)

A difference to remember

Kotlin retains at RUNTIME by default. Which is what reflection needs.

Java does not.

Which is why a Java annotation you went looking for may not be there.

Classes as parameters

Variance, doing real work

annotation class CustomSerializer(
    val serializerClass: KClass<out ValueSerializer<*>>
)

The out is required because KClass<DateSerializer> must be acceptable

where KClass<ValueSerializer<*>> is expected. Unit 9, one page later.

The reflection API

Two APIs

Java reflection — works, but knows nothing of Kotlin concepts.

Kotlin reflection — understands properties, nullability, default values.

The hierarchy

KClass

val kClass = person.javaClass.kotlin       // or Person::class
println(kClass.simpleName)                 // Person
kClass.memberProperties.forEach { println(it.name) }

KCallable and KFunction

fun foo(x: Int) = println(x)
val kFunction = ::foo
kFunction.call(42)

KFunction1, KFunction2 — arity in the type, so a typed reference calls directly.

KProperty

val memberProperty = Person::age
println(memberProperty.get(person))        // 29

KMutableProperty adds set.

What is new here

Person::age is the compile-time-safe entry point — unit 5’s member references.

What is new is obtaining these dynamically,

from a class you have never seen.

A caution built into the packaging

Kotlin reflection needs a separate dependency, kotlin-reflect.

Not on the classpath by default, because of its size.

A deliberate signal.

Building the serializer

The goal

The core

private fun StringBuilder.serializeObject(obj: Any) {
    val kClass = obj.javaClass.kotlin
    kClass.memberProperties.joinToStringBuilder(
            this, prefix = "{", postfix = "}") { prop ->
        serializeString(prop.name)
        append(": ")
        serializePropertyValue(prop.get(obj))
    }
}

That is a complete serializer

A dozen lines.

Working on classes written after it.

Then the annotations enter

val jsonNameAnn = prop.findAnnotation<JsonName>()
val propName = jsonNameAnn?.name ?: prop.name

@JsonExclude · @JsonName · @CustomSerializer

findAnnotation is reified

inline fun <reified T> KAnnotatedElement.findAnnotation(): T?
    = annotations.filterIsInstance<T>().firstOrNull()

Reified because that is the only way to write it.

Unit 9’s mechanism where it is genuinely needed.

Neither half is enough alone

Reflection without annotations → cannot be customised.

Annotations without reflection → metadata nobody reads.

The pattern

The library reflects. The user annotates.

Essentially every JVM serialization, persistence and testing framework.

Deserialization

The harder direction

Serialization reads a fully formed object.

Deserialization must create one from a bag of name/value pairs

that may be incomplete, out of order, or wrongly typed.

The pipeline

Why call is not enough

KCallable.call requires every argument, in order.

JSON guarantees neither.

callBy

fun callBy(args: Map<KParameter, Any?>): R

Order does not matter.

Any parameter left out takes its default value.

Which is exactly what optional means

An API whose shape only makes sense

once you have hit the problem it solves.

And the conversion problem

JSON values arrive untyped. Parameters have declared types.

The library consults the parameter’s KType and converts.

Errors a compiler would have caught, now surfacing at runtime.

What reflection costs

Three costs

Nothing is type-checked.

Calls are slower.

Renaming a property breaks code no compiler will flag.

The rule

Prefer static structure.

Reach for reflection when you have run out of static structure.

Two threads closed

Unit 9’s reified parameters — what makes reflection APIs pleasant.

Unit 9’s star projections — what annotation class parameters need.

Not a coincidence of ordering.

Summary

The six things to carry away

  • The library reflects; the user annotates. Neither is much use alone.
  • One Kotlin property is several JVM elements — use-site targets say which.
  • Annotation arguments must be compile-time constants; val is not enough.
  • Kotlin retains annotations at runtime by default. Java does not.
  • callBy exists because JSON has no order and no obligation to be complete.
  • Reflection defers to runtime what the compiler would check.

Where next

DSL Construction — the deliberate contrast.

APIs that read like a purpose-built language,

built entirely at compile time.