|
| 1 | +# Improvements to annotation use-site targets on properties |
| 2 | + |
| 3 | +* **Type**: Design proposal |
| 4 | +* **Author**: Alejandro Serrano |
| 5 | +* **Contributors**: Alexander Udalov, Ilmir Usmanov, Mikhail Zarechenskii |
| 6 | +* **Discussion**: [KEEP-402](https://github.com/Kotlin/KEEP/issues/402) |
| 7 | +* **Status**: Experimental in Kotlin 2.2.0 |
| 8 | +* **Related YouTrack issue**: [KT-19289](https://youtrack.jetbrains.com/issue/KT-19289/Hibernate-Validator-Annotations-Dropped), [KTIJ-31300](https://youtrack.jetbrains.com/issue/KTIJ-31300/Draft-Validation-Annotation-Email-Doesnt-Work-in-Kotlin-Constructor) |
| 9 | + |
| 10 | +## Abstract |
| 11 | + |
| 12 | +Several kinds of property declarations in Kotlin define more than one use-site target for annotations. If an annotation is applied, one of those targets is chosen using the [defaulting rule](https://kotlinlang.org/docs/annotations.html#java-annotations). This KEEP proposes to change that behavior to choose several targets instead, and introduce a new `all` meta-target. |
| 13 | + |
| 14 | +## Table of contents |
| 15 | + |
| 16 | +* [Abstract](#abstract) |
| 17 | +* [Table of contents](#table-of-contents) |
| 18 | +* [Motivation](#motivation) |
| 19 | + * [Potential misunderstandings](#potential-misunderstandings) |
| 20 | + * [Alignment with Java](#alignment-with-java) |
| 21 | +* [Technical details](#technical-details) |
| 22 | + * [Compiler flags](#compiler-flags) |
| 23 | + * [Migration](#migration) |
| 24 | + * [Exceptions](#exceptions) |
| 25 | + * [Examples](#examples) |
| 26 | + * [Impact](#impact) |
| 27 | +* [Other design choices](#other-design-choices) |
| 28 | + |
| 29 | +## Motivation |
| 30 | + |
| 31 | +Kotlin offers succint syntax to define a constructor parameter, a property, and its underlying backing field, all in one go. |
| 32 | + |
| 33 | +```kotlin |
| 34 | +class Person(val name: String, val age: Int) |
| 35 | +``` |
| 36 | + |
| 37 | +The other side of the coin is that whenever an annotation is given in that position, there is some ambiguity to which of those three elements the annotation should be applied. Kotlin provides a way to be explicit, namely [use-site targets](https://kotlinlang.org/docs/annotations.html#annotation-use-site-targets). |
| 38 | + |
| 39 | +> [!NOTE] |
| 40 | +> Throughout this proposal we shall use annotations from [Jakarta Bean Validation]( https://beanvalidation.org/) in the examples. |
| 41 | +
|
| 42 | +```kotlin |
| 43 | +class Person(@param:NotBlank val name: String, @field:PositiveOrZero val age: Int) |
| 44 | +``` |
| 45 | + |
| 46 | +Declaring a use-site target is not mandatory, though. In case none is given, the **defaulting rule** applies: |
| 47 | + |
| 48 | +> If you don't specify a use-site target, the target is chosen according to the `@Target` annotation of the annotation being used. If there are multiple applicable targets, the first applicable target from the following list is used: `param`, `property`, `field`. |
| 49 | +
|
| 50 | +We argue below that this defaulting rule should be changed. Instead of choosing a single target, the annotation should be applied to _both_ the constructor parameter and the property or field. Furthermore, sometimes it is also important to apply the same annotation to getters and setters, a scenario that currently requires duplication. |
| 51 | + |
| 52 | +### Potential misunderstandings |
| 53 | + |
| 54 | +The main issue with the current defaulting rule is that developers are often surprised when an annotation is _not_ applied to the target they intended. Consider the following example, a variation of the previous one in which the properties are mutable. |
| 55 | + |
| 56 | +```kotlin |
| 57 | +class Person(@NotBlank var name: String, @PositiveOrZero var age: Int) |
| 58 | +``` |
| 59 | + |
| 60 | +Following the defaulting rule, the validation annotations `@NotBlank` and `@PositiveOrZero` are applied _solely_ to the constructor parameter. In practical terms, this means that their values are validated only when first creating the instance, but not on later modifications of the properties. This does not seem like the intended behavior, and may lead to broken invariants. |
| 61 | + |
| 62 | +### Alignment with Java |
| 63 | + |
| 64 | +Java provides [records](https://openjdk.org/jeps/395) since version 16 (experimental since 14). Records are syntactically very close to definition of properties in primary constructors, and they also expand to several declarations in the underlying JVM platform. |
| 65 | + |
| 66 | +```java |
| 67 | +record Person(String name, int age) { } |
| 68 | +``` |
| 69 | + |
| 70 | +The rules for [annotations on record components](https://openjdk.org/jeps/395#Annotations-on-record-components) go even further than the tryad of `param`, `property`, and `field`; they also apply to the property getter and to the Java-only `RECORD_COMPONENT` target. |
| 71 | + |
| 72 | +Since JVM is one of the main targets for Kotlin, we think alignment with the rest of the players is very important. One reason is to make it easier for developers to work on multi-language projects, without having to remember small quirks per language. On top of that, libraries developed with Java in mind may assume the behavior of records, and they would then fail in a very similar scenario in Kotlin. |
| 73 | + |
| 74 | +For full comparison, Scala also applies a [defaulting rule](https://www.scala-lang.org/api/current/scala/annotation/meta.html) giving preference to parameters: |
| 75 | + |
| 76 | +> By default, annotations on (`val`-, `var`- or plain) constructor parameters end up on the parameter, not on any other entity. |
| 77 | +
|
| 78 | +However, they provide a way to create a version of an annotation with a specific target. That way the correct defaulting can be chosen per annotation. |
| 79 | + |
| 80 | +## Technical details |
| 81 | + |
| 82 | +**Param-and-property defaulting rule**: the defaulting rule should read as follows. |
| 83 | + |
| 84 | +> If you don't specify a use-site target, the target is chosen according to the `@Target` annotation of the annotation being used. If there are multiple targets, choose one or more as follows: |
| 85 | +> |
| 86 | +> - If the constructor parameter target `param` is applicable, use it. |
| 87 | +> - If any of the property target `property` or field target `field` is applicable, use the first of those. |
| 88 | +> |
| 89 | +> It is an error if there are multiple targets and none of `param`, `property` and `field` is applicable. |
| 90 | +
|
| 91 | +**New `all` annotation use-site target**: in addition to the existing use-site targets, we define a new meta-target for _properties_. Such an annotation should be propagated, whenever applicable: |
| 92 | + |
| 93 | +- To the parameter constructor (`param`), if the property is defined in the primary constructor, |
| 94 | +- To the property itself (`property`), |
| 95 | +- To the backing field (`field`), if the property has one, |
| 96 | +- To the getter (`get`), |
| 97 | +- To the setter parameter (`set_param`), if the property is defined as `var`. |
| 98 | +- If the class is annotated with `@JvmRecord`, to the Java-only target `RECORD_COMPONENT`. |
| 99 | + |
| 100 | +The last rule ensures that way the behavior of a `@JvmRecord` with annotations using `all` as use-site target aligns with Java records. |
| 101 | + |
| 102 | +Note that the annotation is **not** propagated to types, and potential extension receivers or context receivers/parameters. |
| 103 | + |
| 104 | +The `all` target may **not** be used with [multiple annotations](https://kotlinlang.org/spec/syntax-and-grammar.html#grammar-rule-annotation). It is unclear what the behavior should be when the multiple annotations have different targets. |
| 105 | + |
| 106 | +```kotlin |
| 107 | +@all:[A B] // forbidden, use `@all:A @all:B` |
| 108 | +val x: Int = 5 |
| 109 | +``` |
| 110 | + |
| 111 | +The `all` target may **not** be used with [delegated properties](https://kotlinlang.org/spec/declarations.html#delegated-property-declaration). It is unclear whether the annotation should or should not be propagated to the underlying delegate; in other words, whether the `delegate` target should be part of the propagation. |
| 112 | + |
| 113 | +### Compiler flags |
| 114 | + |
| 115 | +The Kotlin compiler shall provide a flag to change the defaulting behavior. |
| 116 | + |
| 117 | +- `-Xannotation-defaulting=first-only` corresponds to the defaulting rule in version 1.9 of the Kotlin specification. |
| 118 | +- `-Xannotation-defaulting=param-property` corresponds to the new proposed param-and-property defaulting rule. |
| 119 | + |
| 120 | +#### Migration |
| 121 | + |
| 122 | +The param-and-property defaulting rule should become the new defaulting rule in the language. For an orderly transition between the two worlds, we define an additional compiler flag. |
| 123 | + |
| 124 | +- `-Xannotation-defaulting=first-only-warn` behaves as `first-only`; in addition, it raises a warning whenever the following are true: |
| 125 | + - The annotation does _not_ have a explicit use-site target, |
| 126 | + - Both the `param` and one of `property` or `field` targets are allowed for the specific element. |
| 127 | + |
| 128 | +If the user wants to keep the `first-only` behavior but _not_ receive any warnings, the workaround is to explicitly write the use-site target. This is also a future-proof way to keep the current behavior. |
| 129 | + |
| 130 | +> [!TIP] |
| 131 | +> _Tooling support_: in response to this warning, editors supporting Kotlin are suggested to include actions to make them go away. That may include enabling the proposed flag project-wise, or making the use-site target for an annotation explicit. |
| 132 | +
|
| 133 | +In the next version of the Kotlin compiler after this KEEP is approved, the default value of the flag should be `first-only-warn`. After this transitional period, the default value should change to `param-property`. |
| 134 | + |
| 135 | +#### Exceptions |
| 136 | + |
| 137 | +There are two exceptions to the above rule for migration. In these two cases _no_ warning should be issued, even though the target may change between versions. |
| 138 | + |
| 139 | +- Deprecation and suppression annotations, including `@Deprecated` and `@Suppress`. |
| 140 | +- Annotations on properties of annotation classes; in this case instanced are created through special reflection support. |
| 141 | + |
| 142 | +### Examples |
| 143 | + |
| 144 | +Consider [`Email` from Jakarta Bean Validation](https://jakarta.ee/specifications/bean-validation/3.0/apidocs/jakarta/validation/constraints/email), whose targets are defined as follows. |
| 145 | + |
| 146 | +```java |
| 147 | +@Target(value={METHOD,FIELD,ANNOTATION_TYPE,CONSTRUCTOR,PARAMETER,TYPE_USE}) |
| 148 | +public @interface Email { } |
| 149 | +``` |
| 150 | + |
| 151 | +Those Java targets are mapped to the [corresponding ones in Kotlin](https://kotlinlang.org/spec/annotations.html#annotation-targets). In particular, note that `PROPERTY` is _not_ a targe. |
| 152 | + |
| 153 | +Consider now the following code which uses the annotation in two different places. |
| 154 | + |
| 155 | +```kotlin |
| 156 | +data class User(val username: String, /* 1️⃣ */ @Email val email: String) { |
| 157 | + /* 2️⃣ */ @Email val secondaryEmail: String? = null |
| 158 | +} |
| 159 | +``` |
| 160 | + |
| 161 | +Before this proposal, in position 1️⃣ the annotation is applied to the constructor parameter only (target `param`). With this proposal, now it is applied to targets `param` and `field`. There is no change in position 2️⃣, the annotation is still applied only to use-site target `field`. |
| 162 | + |
| 163 | +```kotlin |
| 164 | +// equivalent to |
| 165 | +data class User(val username: String, @param:Email @field:Email val email: String) { |
| 166 | + @field:Email val secondaryEmail: String? = null |
| 167 | +} |
| 168 | +``` |
| 169 | + |
| 170 | +If the `Email` annotation is used with the `all` target instead, |
| 171 | + |
| 172 | +```kotlin |
| 173 | +data class User(val username: String, /* 1️⃣ */ @all:Email val email: String) { |
| 174 | + /* 2️⃣ */ @all:Email val secondaryEmail: String? = null |
| 175 | +} |
| 176 | +``` |
| 177 | + |
| 178 | +Then the annotation is additionally applied as `@get:Email` in the two marked positions. In this case the `get` target comes from "translating" Java's `METHOD` target. If the property was defined as `var`, the additional `set_param` target would also be selected. |
| 179 | + |
| 180 | +This behavior does not only apply to Java annotations. For example, [`IntRange` from `androidx.annotations`](https://developer.android.com/reference/androidx/annotation/IntRange) is defined ["natively" in Kotlin](https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:annotation/annotation/src/commonMain/kotlin/androidx/annotation/IntRange.kt?q=file:androidx%2Fannotation%2FIntRange.kt%20class:androidx.annotation.IntRange). |
| 181 | + |
| 182 | +An example in which the `property` target is involved is given by [`JSONName` from `kjson`](https://github.com/pwall567/kjson-annotations/blob/main/src/main/kotlin/io/kjson/annotation/JSONName.kt). |
| 183 | + |
| 184 | +```kotlin |
| 185 | +@Target(AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.FIELD, AnnotationTarget.PROPERTY) |
| 186 | +annotation class JSONName(val name: String) |
| 187 | +``` |
| 188 | + |
| 189 | +If we consider again the two positions in which the annotation may appear, |
| 190 | + |
| 191 | +```kotlin |
| 192 | +data class User(val username: String, /* 1️⃣ */ @JSONName("mail1") val email: String) { |
| 193 | + /* 2️⃣ */ @JSONName("mail2") val secondaryEmail: String? = null |
| 194 | +} |
| 195 | +``` |
| 196 | + |
| 197 | +there is a change in behavior in 1️⃣ -- with this proposal `@JSONName` is applied to both the parameter and the property --, and no change in 2️⃣ -- `property` is still chosen as the use-site target. |
| 198 | + |
| 199 | +```kotlin |
| 200 | +data class User(val username: String, @param:JSONName("mail1") @property:JSONName("mail1") val email: String) { |
| 201 | + @property:JSONName("mail2") val secondaryEmail: String? = null |
| 202 | +} |
| 203 | +``` |
| 204 | + |
| 205 | +The developer may select the three potential targets by using `@all:JSONName("mail1")` in the definition. |
| 206 | + |
| 207 | +### Impact |
| 208 | + |
| 209 | +To understand the impact of this change, we need to consider whether the annotation was defined in Java or in Kotlin. The reason is that annotations defined in Java may _not_ define `property` as one of their targets. As a consequence, the proposed defaulting rule effectively works as "apply to parameter and field". This is exactly the behavior we want, as described in the _Motivation_ section. |
| 210 | + |
| 211 | +To understand whether the choice between `property` and `field` is required in the rule above, we have consulted open source repositories (for example, [query in GitHub Search](https://github.com/search?q=%40Target%28AnnotationTarget.PROPERTY%2C+AnnotationTarget.FIELD%29+lang%3AKotlin&type=code)). The conclusion is there is an important amount of annotations with both potential targets in the wild, which makes is dangerous to scrape the defaulting between `property` and `field` altogether. |
| 212 | + |
| 213 | +## Other design choices |
| 214 | + |
| 215 | +**Make `all` the default for `@JvmRecord`**: we have considered the possibility of fully aligning Kotlin's behavior with Java's in that case. Although this might be interesting for JVM-only projects, it is very unclear what the behavior should be for Multiplatform projects. Both potential options have strong drawbacks: |
| 216 | + |
| 217 | +- If we make the behavior JVM-only, a different set of targets is chosen depending on the platform. This breaks some expectations around common code, and lacks uniformity. |
| 218 | +- If we make the behavior apply to all platforms, it seems quite un-intuitive that an annotation (`@JvmRecord`) affects how all other annotations are applied. |
| 219 | + |
| 220 | +Furthermore, it is still very early to know how popular frameworks will handle Java records. If at a future time Java interoperability begins to suffer, we shall revisit this choice. |
0 commit comments