Blog Infos
Author
Published
Topics
, , , ,
Published

How to protect your experimental APIs and make opt-in more than just a warning.

Photo by Nick Fewings on Unsplash

I’ve worked on a few internal libraries where parts of the API weren’t ready for prime time — but still needed to be tested or used by early adopters. Sometimes, a simple @Deprecated with a warning wasn’t enough. That’s when I started using @RequiresOptIn, and honestly, it changed the way I think about API design.

In this article, I’ll share how Kotlin’s opt-in mechanism works, what it does under the hood, and how it can be applied to keep APIs safe, explicit, and intentional. If you’re publishing unstable features or just want to prevent accidental usage in shared code, this might be the safety net you’ve been ignoring.

What Is @RequiresOptIn?

Kotlin’s @RequiresOptIn is a compile-time mechanism for marking experimental or internal APIs that shouldn’t be used accidentally. Think of it as Kotlin’s answer to “Hey, this will probably break soon, proceed at your own risk.”

When applied correctly, it forces callers to explicitly opt in before using the marked code — by annotating themselves with @OptIn or using compiler arguments.

But what sets it apart from a simple @Deprecated warning?

  • @Deprecated says: “You can use this, but we don’t recommend it.”
  • @RequiresOptIn says: “You literally can’t use this unless you explicitly agree to take the risk.”

That’s a big deal — and most teams don’t use it at all.

Opt-In explained

To enable opt-in, you define your own marker annotation:

@RequiresOptIn(
    level = RequiresOptIn.Level.ERROR,
    message = "This API is experimental and may change in the future."
)
@Retention(AnnotationRetention.BINARY)
@Target(
    AnnotationTarget.CLASS,
    AnnotationTarget.FUNCTION,
    AnnotationTarget.PROPERTY,
    AnnotationTarget.CONSTRUCTOR
)
annotation class ExperimentalMyApi

Then you mark any “risky” code with that annotation:

@ExperimentalMyApi
fun unstableFunction() { /* ... */ }

Now, if anyone calls unstableFunction(), they’ll get a compiler error unless they opt in:

@OptIn(ExperimentalMyApi::class)
fun useWithCaution() {
    unstableFunction()
}
Misuse #1: Only using it as documentation

A common pitfall is treating @RequiresOptIn as if it’s just documentation. Developers might add it to experimental features and never check who’s using them — or never update the opt-in marker once the API stabilizes.
It becomes a useless signal, rather than a protective gate.

Regularly audit usages of @OptIn in your codebase — they’re a proxy for technical risk.

Misuse #2: Not using it at all in internal libraries

In many teams, internal libraries expose APIs that are still evolving — but there’s nothing preventing their use from production code.

This creates a nightmare when the internal API changes or is removed: you break dozens of modules without warning.

If your team publishes even semi-public modules, start with @RequiresOptIn. You’ll thank yourself during the next refactor.

Hands-on example: an experimental animation DSL

Let’s say I’m building a small Kotlin DSL for physics-based animations — part of a private multiplatform library not ready for production use.

@RequiresOptIn(message = "This animation DSL is experimental and may change.")
annotation class ExperimentalAnimationDsl

 

@ExperimentalAnimationDsl
fun springAnimation(
    stiffness: Float,
    dampingRatio: Float,
    onUpdate: (Float) -> Unit
) { /* ... */ }

 

By enforcing @OptIn(ExperimentalAnimationDsl::class), I ensure that only early adopters — who are explicitly aware of the experimental state — can use it.

Controlling Opt-In in Gradle: Local vs Global

You can control how easy it is to use experimental APIs by adjusting your Gradle setup:

Global opt-in:

kotlin {
    sourceSets.all {
        languageSettings.optIn("com.example.ExperimentalAnimationDsl")
    }
}

this enables opt-in for the entire source set. All code in these source sets can use the experimental API without an explicit @OptIn annotation at each call site.
This approach is convenient for prototypes or when your whole module is ready to work with experimental features, but it lowers the barrier and makes it easier for experimental APIs to “leak” into stable code.

Local, explicit opt-in:

If you don’t add this to your Gradle build, every usage of the experimental API must be marked with an explicit @OptIn(...) annotation at the call site (or enabled via compiler flags for specific files). This forces developers to consciously acknowledge the risks each time they use the API, making accidental usage much less likely.

This mirrors what Kotlin itself does with:

  • @ExperimentalCoroutinesApi
  • @InternalCoroutinesApi
  • @DelicateCoroutinesApi

By default, Kotlin libraries require explicit, local opt-in to help prevent accidental adoption of unstable or risky APIs. You should choose the approach that matches your team’s risk tolerance and code review practices.

What happens under the hood?

When Kotlin encounters @RequiresOptIn, here’s exactly what happens at compile time:

  1. The compiler resolves function and class calls, checking if they’re marked with an opt-in annotation.
  2. If it finds an experimental API usage, it looks for an explicit @OptIn at the calling site (or compiler flags).
  3. This check propagates through call chains: every method invoking an experimental API must explicitly opt-in.
  4. Annotations marked with Retention.BINARY are preserved as metadata attributes in compiled .class files. Kotlin’s compiler reads this metadata at compile time to enforce opt-in checks across module boundaries.
  5. There’s no runtime overhead; once the code compiles successfully, the opt-in annotation has no further effect at runtime.

That’s the internal machinery that makes @RequiresOptIn effective and lightweight at the same time.

Why @Retention(BINARY)?

When you define your opt-in annotation as:

@RequiresOptIn
@Retention(AnnotationRetention.BINARY)
annotation class ExperimentalMyApi

you’re explicitly instructing Kotlin to retain the annotation in the compiled .class file metadata, but not expose it at runtime via reflection.
Here’s exactly why Kotlin chose this retention level:

  • BINARY (Recommended)
  • The annotation is preserved in compiled .class files as metadata.
  • This allows Kotlin’s compiler to enforce opt-in checks even across compiled library boundaries.
  • At runtime, there’s no overhead, because the annotation isn’t visible via reflection.
  • SOURCE (Not recommended)
  • The annotation is discarded during compilation.
  • Opt-in enforcement cannot propagate to other modules or compiled libraries.
  • As a result, the opt-in mechanism becomes ineffective outside of your current module.
  • RUNTIME (Unnecessary)
  • The annotation stays available at runtime through reflection.
  • This adds no practical benefit for compile-time opt-in checks.
  • Slightly increases the runtime metadata overhead with no real gain.

Use BINARY.
It’s exactly what Kotlin expects and uses internally in its own standard libraries and popular Kotlin-based frameworks.

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

, ,

Kobweb:Creating websites in Kotlin leveraging Compose HTML

Kobweb is a Kotlin web framework that aims to make web development enjoyable by building on top of Compose HTML and drawing inspiration from Jetpack Compose.
Watch Video

Kobweb:Creating websites in Kotlin leveraging Compose HTML

David Herman
Ex-Googler, author of Kobweb

Kobweb:Creating websites in Kotlin leveraging Compose HTML

David Herman
Ex-Googler, author o ...

Kobweb:Creating websites in Kotlin leveraging Compose HTML

David Herman
Ex-Googler, author of Kob ...

Jobs

Does it affect ABI or bytecode?

Adding @RequiresOptIn inserts metadata annotations into compiled .class files, but doesn’t alter method signatures or runtime binary compatibility. However, adding it to an already public API is a source-breaking change—existing clients will need to explicitly opt-in upon recompilation.

So:

  • Safe to use during early API design.
  • Use caution when retrofitting it into published libraries.
Is @OptIn just a Suppress?

No. Unlike @Suppress(...), which just quiets IDE/compiler warnings, @OptIn(...) is a formal declaration of intent — enforced by the compiler and understood across module boundaries.

It’s part of Kotlin’s semantic type system, and the compiler treats it as such.

Conclusion

Kotlin’s @RequiresOptIn is more than just a warning—it’s your API’s built-in safety net. It turns implicit trust into explicit contracts, clearly marking boundaries and guiding your users toward intentional choices.

Whether you’re creating internal libraries, crafting Compose extensions, or managing Kotlin Multiplatform modules, don’t leave your APIs exposed to accidental misuse. Use @RequiresOptIn proactively—define clear boundaries, communicate expectations explicitly, and ensure your future self won’t be fixing avoidable mistakes.

Good APIs deserve good fences. Build them wisely.

What strategies have you used to communicate experimental or internal APIs to your team — beyond just code comments?

You might also like:
. . .

Hands-on insights into mobile development,
engineering, and team leadership.
📬 Follow me on Medium

This article was previously published on proandroiddev.com.

Menu