Lecture notes — Annotations and Reflection
ver. 1.0.0
Where we are
Up to now, every technique in this course has assumed the compiler knows what you are working with. Generics stretched that assumption; this unit drops it.
The problem. Suppose you are writing a JSON serialization library. You cannot know the classes your users will hand you — they have not been written. You need to inspect an arbitrary object at runtime, discover its properties, and turn them into JSON. And you need a way for the user to say “call this property first_name in the output” without you knowing anything about their class in advance.
The two mechanisms. Annotations attach metadata to a declaration. Reflection reads a class’s structure at runtime. Neither is exotic — if you have used a testing framework, an ORM or a serialization library on the JVM, you have used both from the outside. This unit is the view from inside.
The running example is JKid, a small JSON library built in pure Kotlin over the course of the unit. It is small enough to follow completely and real enough to be worth building.
What you will be able to do
apply-annotations-with-arguments— Apply annotations to declarations and expressions, passing arguments correctly.use-annotation-targets— Use a use-site target to say which bytecode element an annotation applies to.declare-annotations— Declare your own annotation class with parameters.use-meta-annotations— Control an annotation’s applicability and lifetime with meta-annotations.pass-classes-as-annotation-parameters— Pass a class reference as an annotation parameter using KClass.navigate-the-reflection-api— Navigate the Kotlin reflection API: KClass, KCallable, KFunction, KProperty.serialize-an-object-by-reflection— Write code that serializes an arbitrary object by inspecting it at runtime.deserialize-with-callby— Explain how callBy constructs an object when only some parameters are known.judge-when-to-use-reflection— Judge when reflection is the right tool and what it costs.
What we will cover
- Applying annotations — syntax, argument restrictions, use-site targets.
- Declaring them — annotation classes,
@Target,@Retention, class parameters. - The reflection API —
KClass,KCallable,KFunction,KProperty. - Building the serializer — where the two halves meet.
- Deserialization — and why
callByexists. - What reflection costs — and when it is the wrong tool.
Reflection defers to runtime what the compiler would otherwise check.
That is the whole power and the whole cost. The unit closes by asking when the trade is worth making — keep the question open while you read.
Applying annotations
@Test fun testTrue() {
Assert.assertTrue(true)
}
@Deprecated("Use removeAt(index) instead.", ReplaceWith("removeAt(index)"))
fun remove(index: Int) { }An annotation is applied with @ before the declaration, and may take arguments, named or positional, as with any call.
@Deprecated is the instructive example, because Kotlin’s version does more than Java’s: alongside the message it takes a ReplaceWith pattern, so the IDE can offer an automatic fix. That is metadata doing real work rather than merely documenting.
Permitted argument types
More limited than a normal function’s: primitives, strings, enums, class references, other annotations, and arrays of these.
The compile-time constant rule
const val TEST_TIMEOUT = 100L
@Test(timeout = TEST_TIMEOUT) fun testMethod() { }Arguments must be known at compile time, because they are written into the class file. An ordinary val is not enough — the compiler must be told the value is constant, with const val.
This catches people the first time. The error message is clearer once you know the reason behind it.
Annotating expressions
Kotlin also permits annotations on expressions, not only declarations — @Suppress on a single statement rather than a whole function is the common use.
Learning outcomes
- apply-annotations-with-arguments: Apply annotations to declarations and expressions, passing arguments correctly.
Concepts
- annotations: metadata attached to a declaration, readable by tools and at runtime
Annotation targets

The problem
A Kotlin property is a single declaration, but it compiles to several JVM elements: a backing field, a getter, sometimes a setter, and — if declared in a primary constructor — a constructor parameter.
An annotation on the property is therefore ambiguous. A Java library expecting the annotation on the field will not find one placed on the getter, and the resulting failure is silent and confusing.
The solution
class HasTempFolder {
@get:Rule
val folder = TemporaryFolder()
}A prefix before the colon names the element. The available targets:
| Target | Applies to |
|---|---|
property |
the Kotlin property (invisible to Java) |
field |
the backing field |
get / set |
the accessor |
param |
the constructor parameter |
setparam |
the setter’s parameter |
receiver |
an extension’s receiver |
delegate |
the field holding a delegate |
file |
the whole file — must precede the package directive |
@get:Rule in JUnit is the canonical example, and the one you are most likely to meet first: JUnit requires the annotation on a method, and Kotlin puts it on the property by default.
Most annotation problems when using Java libraries from Kotlin are target problems.
When an annotation appears to be ignored, the target is the first thing to check — before the classpath, before the version, before anything else.
Kotlin-specific targets also exist for controlling Java interoperability — @JvmName, @JvmStatic, @JvmOverloads, @JvmField. Each adjusts how a declaration appears to Java callers, and together they are the interoperability escape hatches earlier units alluded to.
Learning outcomes
- use-annotation-targets: Use a use-site target to say which bytecode element an annotation applies to.
Concepts
- use-site-targets: one Kotlin property is several JVM elements, so the annotation must say which
Declaring your own annotations
annotation class JsonExclude
annotation class JsonName(val name: String)The keyword is annotation class, and parameters are declared as val constructor properties. The class has no body — an annotation carries data and nothing else, so there is nothing for a body to hold.
Applied to a class, the JKid annotations look like this:

@JsonName and @JsonExclude.data class Person(
@JsonName("alias") val firstName: String,
@JsonExclude val age: Int? = null
)Meta-annotations
Annotations applied to annotation declarations. Two matter:
@Target(AnnotationTarget.PROPERTY)
annotation class JsonExclude
@Retention(AnnotationRetention.RUNTIME)
annotation class JsonName(val name: String)@Target restricts where the annotation may be applied. Declaring it is worth the effort: it converts a whole category of misuse into a compile error.
@Retention controls whether the annotation is kept in the .class file and visible at runtime. Kotlin’s default is RUNTIME, which is what reflection needs. Java’s default is not, which is a difference to remember when reading Java annotation declarations — and the reason a Java annotation you expected to find reflectively may not be there.
Classes as parameters

KClass<CompanyImpl> is a subtype of KClass<out Any>, which is why the out is needed.Sometimes an annotation must name a class:
annotation class DeserializeInterface(val targetClass: KClass<out Any>)
data class Person(
val name: String,
@DeserializeInterface(CompanyImpl::class) val company: Company
)KClass is Kotlin’s counterpart to Java’s Class, and the argument is written ThatClass::class.
Generic classes as parameters is the refinement — when the annotation should accept only classes implementing a particular generic interface:
annotation class CustomSerializer(
val serializerClass: KClass<out ValueSerializer<*>>
)That is unit 9’s variance and star projections used in earnest, a page after learning them. The out is required because KClass<DateSerializer> must be acceptable where KClass<ValueSerializer<*>> is expected — which is exactly the covariance question from the previous unit.
Learning outcomes
- declare-annotations: Declare your own annotation class with parameters.
- use-meta-annotations: Control an annotation’s applicability and lifetime with meta-annotations.
- pass-classes-as-annotation-parameters: Pass a class reference as an annotation parameter using KClass.
Concepts
- meta-annotations:
@Targetand@Retention, and Kotlin’s runtime-by-default choice - kclass: a class reference as an annotation argument, with variance doing real work
The reflection API
Reflection is a set of APIs for accessing a program’s own structure at runtime — its classes, properties and functions — dynamically, when the types are not known statically.
Kotlin has two reflection APIs, and knowing which you are using avoids confusion:
- Java reflection (
java.lang.reflect) — works on Kotlin classes, since they compile to regular JVM classes, but knows nothing of Kotlin-specific concepts - Kotlin reflection (
kotlin.reflect) — understands properties, nullable types, default parameter values and the rest
The four core interfaces

KAnnotatedElement, KClass, KCallable, KFunction, KProperty.KClass represents a class:
val person = Person("Alice", 29)
val kClass = person.javaClass.kotlin // or Person::class
println(kClass.simpleName) // Person
kClass.memberProperties.forEach { println(it.name) }KCallable is the shared supertype of anything callable, and provides call:
fun foo(x: Int) = println(x)
val kFunction = ::foo
kFunction.call(42)KFunction is a function, with subtypes carrying arity — KFunction1, KFunction2 — so a correctly typed reference can be invoked directly rather than via call.
KProperty is a property, with get to read a value from an instance:
val memberProperty = Person::age
println(memberProperty.get(person)) // 29KMutableProperty adds set.
Property references — Person::age — are the compile-time-safe entry point, and connect to the member-reference syntax from unit 5. What is new here is obtaining these objects dynamically, from a class you have never seen, which is what makes a general-purpose library possible.
Kotlin reflection requires a separate dependency, kotlin-reflect, which is not on the classpath by default because of its size.
That is a deliberate signal: reflection is not something to reach for casually.
Learning outcomes
- navigate-the-reflection-api: Navigate the Kotlin reflection API: KClass, KCallable, KFunction, KProperty.
Concepts
- kclass: the class, its name, and its members
- kcallable: anything callable, with
call - kfunction: a function, with arity in the type
- kproperty: a property, with
getand — when mutable —set
Building the serializer

Person object and its JSON equivalent.This section is the payoff, and it is short — which is the point. Reflection makes a genuinely general serializer small.
The core
private fun StringBuilder.serializeObject(obj: Any) {
val kClass = obj.javaClass.kotlin
val properties = kClass.memberProperties
properties.joinToStringBuilder(this, prefix = "{", postfix = "}") { prop ->
serializeString(prop.name)
append(": ")
serializePropertyValue(prop.get(obj))
}
}Obtain the KClass, iterate memberProperties, read each value with get, emit a name/value pair. That is a complete serializer for simple objects, in a dozen lines, working on classes written after it.
Then the annotations enter
private fun StringBuilder.serializeProperty(prop: KProperty1<Any, *>, obj: Any) {
val jsonNameAnn = prop.findAnnotation<JsonName>()
val propName = jsonNameAnn?.name ?: prop.name
serializeString(propName)
append(": ")
val value = prop.get(obj)
val jsonValue = prop.getSerializer()?.toJsonValue(value) ?: value
serializePropertyValue(jsonValue)
}While walking the properties, check each for annotations:
@JsonExclude— skip the property@JsonName— emit the given name instead of the property’s own@CustomSerializer— instantiate the named serializer class and delegate
Retrieving them uses findAnnotation, a reified extension function on KAnnotatedElement:
inline fun <reified T> KAnnotatedElement.findAnnotation(): T?
= annotations.filterIsInstance<T>().firstOrNull()Reified because that is the only way to write it — which is unit 9’s mechanism appearing where it is genuinely needed rather than as a demonstration.
Neither half is enough alone.
- Reflection without annotations gives a serializer that cannot be customised.
- Annotations without reflection give metadata nobody reads.
Together they give the pattern used by essentially every JVM serialization, persistence and testing framework:
the library reflects, the user annotates.
Understanding it from the inside changes how you read those frameworks from the outside — and makes it obvious why they behave as they do when something goes wrong.
Learning outcomes
- serialize-an-object-by-reflection: Write code that serializes an arbitrary object by inspecting it at runtime.
Concepts
- kproperty: walked dynamically, read with
get, and interrogated for its annotations
Deserialization and callBy

Why this direction is harder
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 of the wrong type.
The architecture is worth noting for its own sake: a lexer and parser produce tokens and events, and a separate seed-based layer builds objects from them. Parsing distinct from object construction is standard in this kind of library, and it is why you can swap the parser without touching the object building.
Why call is not enough
KCallable.call requires every argument, in declaration order. JSON gives you neither guarantee: fields arrive in any order, and optional ones may be absent entirely.
callBy
interface KCallable<out R> {
fun callBy(args: Map<KParameter, Any?>): R
}It takes a map from KParameter to value, so order does not matter — and any parameter left out of the map takes its default value.
That is precisely the semantics an optional JSON field needs, and it is one of those APIs whose shape only makes sense once you have hit the problem it solves.
The type conversion problem
JSON values arrive untyped; the constructor parameter has a declared type. The library must consult the parameter’s KType and convert, and handle the cases where conversion is impossible.
Those are the errors a compiler would have caught, now surfacing at runtime. That is the trade named in the opening section, made concrete — and it is why a deserialization failure gives you a message about a field rather than a red squiggle in your editor.
The takeaway beyond JKid: this is how every reflective object mapper on the JVM works. Knowing it explains both their power and their characteristic failure modes.
Learning outcomes
- deserialize-with-callby: Explain how callBy constructs an object when only some parameters are known.
Concepts
- seed-pattern: parsing separated from object construction
- kcallable:
callBywith a parameter map, so order and defaults both work
What reflection costs
- nothing is type-checked
- calls are slower
- renaming a property breaks code no compiler will flag
It is the right tool when you genuinely cannot know the type in advance — libraries, frameworks, serializers, test runners — and the wrong one when a simpler design would serve.
The general rule:
Prefer static structure; reach for reflection when you have run out of static structure.
Two threads from earlier units closed here. Unit 9’s reified type parameters turned out to be what makes reflection APIs pleasant to call, and its star projections turned out to be what annotation class parameters need. That was not a coincidence of ordering.
Learning outcomes
- judge-when-to-use-reflection: Judge when reflection is the right tool and what it costs.
- apply-annotations-with-arguments: Apply annotations to declarations and expressions, passing arguments correctly.
- use-annotation-targets: Use a use-site target to say which bytecode element an annotation applies to.
- declare-annotations: Declare your own annotation class with parameters.
- use-meta-annotations: Control an annotation’s applicability and lifetime with meta-annotations.
- pass-classes-as-annotation-parameters: Pass a class reference as an annotation parameter using KClass.
- navigate-the-reflection-api: Navigate the Kotlin reflection API: KClass, KCallable, KFunction, KProperty.
- serialize-an-object-by-reflection: Write code that serializes an arbitrary object by inspecting it at runtime.
- deserialize-with-callby: Explain how callBy constructs an object when only some parameters are known.
Conclusion
The library reflects; the user annotates.
Neither mechanism is much use alone. Together they are the pattern behind every JVM serializer, ORM and test runner — and JKid is small enough to see the whole of it.
One Kotlin property is several JVM elements, and use-site targets say which.
@get:Rule,@field:JsonName. When an annotation seems to be ignored, this is the first thing to check.Annotation arguments must be compile-time constants, and
valis not enough.They are written into the class file.
const valis how you say so.Kotlin retains annotations at runtime by default; Java does not.
A small difference with real consequences when you go looking for a Java annotation reflectively and it is not there.
callByexists because JSON has no order and no obligation to be complete.calldemands every argument in sequence. A map ofKParameterto value lets defaults fill the gaps — which is exactly what an optional field means.Reflection defers to runtime what the compiler would check.
That is the power and the cost. Use it where the type genuinely cannot be known, and reach for static structure everywhere else.
Where next
The final unit, DSL Construction, is the deliberate contrast. It builds APIs that read like a purpose-built language — and it does so entirely at compile time, with lambdas with receivers and the invoke convention rather than runtime introspection.
Two routes to flexibility: one paid for at runtime, one at compile time. Having seen the runtime one, the compile-time one is easier to appreciate.