zsmb.coRecomposing...



All About Opt-In Annotations

2021.09.28. 15h • Márton Braun

The opt-in mechanism in Kotlin allows you to mark APIs that should be used carefully – or perhaps not at all. If you mark a declaration (a class, a function, a property, anything really) as opt-in required, using it will produce a warning or error in the code, prompting the user to explicitly opt in to using it. This can guarantee that there has been a conscious decision made about using the API on the use site.

This is very similar to how you deprecate declarations to prevent people from using them. You can read more about deprecations in Maintaining Compatibility in Kotlin Libraries.

Opt-in APIs are really useful if you’re building libraries, but it also applies if you want to control the visibility and usages of APIs within any multi-module project. You also have to know how to interact with opt-in APIs correctly as a user.

In this article, we’ll look at everything you need to know about opt-in APIs:

And we’ll also touch on some auxiliary topics around opt-in:

We have a lot of ground to cover, so let’s get started. Feel free to use the links above to jump around to different parts of this guide!

Creating Opt-In APIs

First, let’s see how you can create APIs that will require their users to opt-in to using them. This is done in two steps:

  1. Creating an opt-in marker annotation
  2. Marking APIs as opt-in required

Creating an Opt-in Marker Annotation

To create an opt-in marker annotation, create a new annotation class and annotate it with RequiresOptIn:

package com.example.lib.core

@RequiresOptIn(
    level = RequiresOptIn.Level.WARNING,
    message = "This is an experimental API. It may be changed or removed in the future."
)
public annotation class ExperimentalLibraryApi

This annotation has very similar properties to the @Deprecated annotation. Let’s review what they are.

First, you can set a severity level:

  • WARNING will show a yellow warning on the call site and a warning during compilation. This is a “soft” warning, as it can be ignored and the code will still compile.
  • ERROR will cause a “hard” compilation error on the call site. Anyone using an API that has this opt-in level must explicitly opt-in to using it, otherwise their code won’t compile.

You can also set a message that describes what the given opt-in marker annotation indicates. For this example, we’re creating an annotation to mark APIs that are still experimental in a library, but are expected to become stable in later versions.

Note that marking experimental APIs is just one use case for opt-in annotations. See the Use Cases and Conventions below for other examples.

You’ll notice that the usage of the RequiresOptIn annotation itself also produces the warning. This is because the opt-in system of the Kotlin Standard Library itself is still experimental. You can track the development progress on this YouTrack issue – it’s currently expected to become stable in Kotlin 1.7.

While this system might not be officially stable, it’s already being widely used across popular Kotlin libraries.

To make that warning go away, you’ll have to opt in to using Kotlin’s opt-in system. You can do so by adding the following code to your module’s build configuration, which adds an argument to the Kotlin compiler doing exactly that: opting in to opt-in.

tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all {
    kotlinOptions {
        freeCompilerArgs += [
            '-Xopt-in=kotlin.RequiresOptIn',
        ]
    }
}

Marking APIs as Opt-in Required

With the marker annotation created, you can now mark pieces of API as experimental! For example, you can annotate a function like this:

@ExperimentalLibraryApi
public fun someExperimentalApi()

Calling this function will now produce a warning on the call site:

Warning on the call site of the function

You can also place these markers on larger scopes. For example, you can mark an entire class:

@ExperimentalLibraryApi
class Stack {
    fun push(data: Int)
    fun pop(): Int
}

With this in place, any usage of the class’ APIs (as well as the usage of the type itself) will produce warnings:

Warnings on the call site of the class

Let’s see how you can handle these on the call site.

Using Opt-In APIs

There are several ways of making the warnings and errors produced by calling opt-in APIs go away. Let’s see when you want to use each of them.

Propagating the Opt-in Requirement

One way to use an opt-in API is to mark the code that’s calling it with the same opt-in annotation, propagating the requirement further:

@ExperimentalLibraryApi
public fun anotherExperimentalApi() {
    someExperimentalApi()
}

This allows you to use other APIs that are marked with the same annotation within the implementation of this function. However, anyone using this function will now have to deal with the opt-in requirement as well.

This is useful within your own code to make sure that you don’t accidentally create stable APIs that rely on experimental APIs in their implementation. If something relies on an experimental declaration, it too should likely be experimental.

However, you shouldn’t do this in client code, as that’d just be putting off the issue. Instead, you want to actually opt in there. We’ll look at this next.

Opting in an Entire Module

If you want to enable all usages of a certain kind of opt-in API in a module, you can use the same -Xopt-in compiler option that you used to opt in to using opt-in earlier, but provide the specific annotation as the value.

This configuration allows all usages of our experimental APIs within its module:

tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all {
    kotlinOptions {
        freeCompilerArgs += [
            '-Xopt-in=com.example.lib.core.ExperimentalLibraryApi',
        ]
    }
}

You should use this with care, as enabling a certain opt-in API module-wide can lead to creating lots of usages of that API accidentally. Since there are no warnings in the code this way, you can easily miss these usages.

On the other hand, this fits certain use cases really well, such as opting in to using internal APIs that are shared between different modules of the same library.

Opting in Locally

A more precise approach is opting in locally. You can do this for each usage of opt-in APIs, using the OptIn annotation:

public fun clientCode() {
    @OptIn(InternalMyLibraryApi::class)
    someExperimentalApi()
}

You’ll notice that the usage of the OptIn annotation itself also produces the warning. This, again, is due to the system itself being experimental. Interestingly, you have to opt in to kotlin.RequiresOptIn in this case as well:

tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).all {
    kotlinOptions {
        freeCompilerArgs += [
            '-Xopt-in=kotlin.RequiresOptIn',
        ]
    }
}

With the configuration above, you can now freely use OptIn to suppress opt-in related warnings and errors. This comes with the benefit that each usage of opt-in APIs will be clearly visible in the code thanks to the OptIn annotation being present on the use site.

You can opt-in at the statement level, like seen above, but also in broader scopes, such as for an entire function:

@OptIn(InternalMyLibraryApi::class)
public fun clientCode() {
    someExperimentalApi()
    someExperimentalApi()
}

Or even an entire file, using an annotation use-site target, @file: :

@file:OptIn(InternalMyLibraryApi::class)

public fun firstFunction() {
    someExperimentalApi()
}

public fun secondFunction() {
    someExperimentalApi()
}

Next, let’s expand beyond using these annotations to mark experimental APIs, and see what other scenarios they can be useful in.

Use Cases and Conventions

Some of the best examples of opt-in APIs are in first-party Kotlin libraries. A great example is the kotlinx.coroutines library, which uses four different opt-in marker annotations across its API.

The annotation naming conventions used in this library are also widely adopted in other libraries. You should consider aligning your own opt-in usages with this as well.

These opt-in annotations all follow the [Prefix][LibraryName]Api pattern, and each prefix carries different semantics. Let’s see what each of them mean:

  • ExperimentalCoroutinesApi: Perhaps the most frequently used convention, it marks APIs that are not stable yet. These might have known issues or ongoing design discussions that can change the API or its behaviour in the future.
  • InternalCoroutinesApi: Marks APIs that are only exposed for cross-module usage within a single library group, and should not be relied on by end users. They should be considered internal and can change or be removed at any time. See Mastering API Visibility in Kotlin for a detailed example of how to use these.
  • ObsoleteCoroutinesApi: A more niche marker than previous ones, this marks APIs that should not be used anymore. However, they are not deprecated yet, as they don’t have replacements ready that users could migrate to.
  • DelicateCoroutinesApi: Another niche convention, delicate APIs should only be used in very specific situations, and users should make sure they understand the implications of using such APIs.

On Android, Google’s own Jetpack libraries also make generous use of opt-in APIs. In the API guidelines document for Jetpack, using opt-in annotations is the recommended way of “annotating API surfaces as unstable”.

As a result, you’ll see lots of these annotations when interacting with Jetpack libraries. They are especially widespread in Jetpack Compose libraries, given that the framework is still very new. Some common examples of these:

As you can see, these usually come in pairs, and a separate pair is created for each of the different Compose packages.

They are generally not documented in Kdoc, but you can find out what their purpose is by reading the provided message. A good place to do this is on cs.android.com. For example, you can find the sources of InternalFoundationApi here, and the sources of ExperimentalFoundationApi here, in the same package.

They, of course, follow the conventions layed out by kotlinx.coroutines above.

Java Interop

Until now, we’ve discussed using Kotlin’s own opt-in annotations: kotlin.RequiresOptIn and kotlin.OptIn. Usages of these are verified by the Kotlin compiler, which produces warnings and errors around them as needed.

In most cases, the API marked with opt-in annotations is public, but the annotation being present deters clients from using it. However, when Java source code uses these APIs, they’ll only see it as regular public API. The Java compiler does not understand or respect these opt-in requirements.

You can either ignore this and accept that Java users (if they exist) will be able to access these APIs freely, and not be warned at all, or you can take extra steps to make sure that Java clients don’t access opt-in APIs accidentally.

One solution is to mark your opt-in declarations with @JvmSynthetic as well. Synthetic declarations are inaccessible from Java source code, preventing all Java usages of these APIs (with no way to opt-in).

@JvmSynthetic
@ExperimentalLibraryApi
public fun someExperimentalApi()

If you’re on Android, keep reading for another one.

Opt-In on Android

Android is now officially Kotlin-first, but Java is also still a fully-supported language on the platform. To work around the Java compatibility issue described above, Android has its own set of opt-in annotations (androidx.annotation.RequiresOptIn and androidx.annotation.OptIn), which can be used the exact same way as described earlier for the Kotlin annotations.

The difference is in how these are verified. Instead of the Kotlin compiler performing the checks, verifying these Android-specific annotations is up to the Android Lint tool. As Lint checks are performed on the UAST (Unified Abstract Syntax Tree), these annotations are verified not only in Kotlin, but also in Java code. This means that the opt-in marker annotation, its usage, and the code using the opt-in APIs can all be in either language.

To use these annotations in your project, you need to add a dependency on the annotation-experimental artifact:

dependencies {
    implementation("androidx.annotation:annotation-experimental:1.1.0")
}	

Note that you can still use the Kotlin opt-in system on Android, you’ll just miss out on Java support.

API Verification

If you mark some pieces of public API in your module as experimental or internal using Opt-In annotations, you will probably want to ignore them when checking for changes (especially breaking changes) in your public API.

A common tool you can use for Kotlin projects is the Binary compatibility validator plugin. This plugin calls annotations that mark API that shouldn’t be considered public “non-public markers”.

These can be configured in the plugin’s configuration block by adding the fully qualified names of the marker annotations to nonPublicMarkers:

apiValidation {
    /**
     * Set of annotations that exclude API from being public.
     * Typically, it is all kinds of `@InternalApi` annotations 
     * that mark effectively private API that cannot be actually 
     * private for technical reasons.
     */
    nonPublicMarkers += [
        "com.example.lib.core.ExperimentalLibraryApi",
        "com.example.lib.core.InternalLibraryApi",
    ]
}

APIs annotated with non-public markers will be excluded from the API dumps of the validator plugin (they won’t show up in the generated .api files).

Conclusion

That’s – hopefully – everything you’d ever want to know about opt-in annotations, and more. With this, you should now be ready to use these annotations in your own library or multi-module projects as well, whether you just need to interact with experimental APIs while building your apps or create your own opt-in APIs.

Thanks to István Juhos, Arnaud Giuliani, Thomas Künneth, and Annyce Davis for taking an early look and reviewing this post :)



You might also like...

Mastering API Visibility in Kotlin

When designing a library, minimizing your API surface - the types, methods, properties, and functions you expose to the outside world - is a great idea. This doesn't apply to just libraries: it's a consideration you should make for every module in a multi-module project.

Announcing requireKTX

Introducing a new library to conveniently require values from common Android types.

Maintaining Compatibility in Kotlin Libraries

Not breaking client code is an essential duty of a library developer. Let's take a look at a couple rarely discussed issues you might face in this area.

Sealed goodies coming in Kotlin 1.5

Kotlin 1.5 will bring exciting new features, among them improvements to sealed classes and an introduction of sealed interfaces. Let's take a look at what that will look like!