Deep dive into API visibility in Kotlin

Helder Pinhal
Helder Pinhal
Sep 10 2021
Posted in Engineering & Technology

Crafting precise APIs with Kotlin

Deep dive into API visibility in Kotlin

When designing libraries, one important aspect is the public API surface. This means types, methods, properties, etc. It is often good practice minimizing the libraries' functionality that is accessible to consumers.

This will lead to more concise and easier to use APIs, indirectly, resulting in easier-to-maintain projects. Additionally, when changes are made to the actual implementations, it prevents making breaking changes by mistake.

While this article is focused on libraries, the same is applicable to multi-module projects. Even when a module is meant to be consumed by a single application, it's best to treat it as a fully independent piece of software — as it is.

Let's take a look at some visibility options that Kotlin provides.

Internal visibility

Our best friend to keep implementation details out of the public API is the internal keyword. This visibility is similar to the package-private visibility in Java, but instead it's private to the entire module. This is an efficient way to limit what is available to your consumers while fully allowing its usage inside your module.

Java interoperability

Since the internal keyword is not something Java understands, a Java consumer is capable to access internal Kotlin APIs. When Kotlin is compiled, it will mangle the names of methods, properties, etc, that have been marked as internal to prevent their misuse by Java consumers.

Considering the following example, the internal bar() method is visible and accessible by Java consumers as bar$foo_sdk_name_class_name();

object Foo {
    internal fun bar() { }
}

// Library consumer
void consumerCode() {
    Foo.INSTANCE.bar$foo_sdk_android_foo();
}

If you are using the IDEA IDEs, you will see a warning when you try to do something like this. However, it will not break when you compile the project.

One possible workaround to this caveat, is to leverage the @JvmSynthetic annotation. This will instruct the Kotlin compiler to not expose the method in the JVM bytecode, thus preventing Java source code from referencing it.

object Foo {
    @JvmSynthetic
    internal fun bar() { }
}

// Library consumer
void consumerCode() {
    // ERROR: unable to find symbol
    Foo.INSTANCE.bar$foo_sdk_android_foo();
}

Explicit API mode

Considering that all Kotlin declaration that omit the access modifier will be public, it's quite easy to miss marking an object that's meant to be used for internal purposes as such. Kotlin 1.4 introduced the Explicit API mode which is meant to aid library developers to write clearer and less break-prone APIs.

Among other rules, this will force you to explicitly declare every access modifier and every type for public declarations. This aims to reduce mistakes in public declarations and prevent API breaking changes due to type inferences.

To illustrate this, let us take a look at the following code:

val foo = 1

Assuming this is our API surface, if we were to modify that declaration to val foo = 1.0 it would, potentially, break client code since there is a different return type due to type inference.

When enabling the Explicit API mode, the compiler would force, or warn, us to be specific, like so:

public val foo: Int = 1

How to enable it

The Explicit API mode is not enabled by default. In order to do so, you need to add a compiler flag.

kotlinOptions {
    freeCompilerArgs += [
            '-Xexplicit-api=strict', // or '-Xexplicit-api=warning'
    ]
}

The strict mode will throw an error for each declaration that doesn't comply with the Explicit API mode. On the other hand, the warning mode is far more forgiving, showing warnings for those declarations.

If you already have a large library, you're probably wondering how much work this might be. As a matter of opinion, there's two possible ways to go about it if you're planning to enable this.

Since the Explicit API mode is enabled per module, you can incrementally adopt it. Or start new modules with it. This will probably be the easier way forward.

On the other hand, biting the proverbial bullet and enabling this for all of your modules will force you to review your entire project. Although this may be a lot of work, you can take this opportunity to ensure the visibility requirements of your library.

Published API

Another scenario you may face is dealing with (inline functions)[https://kotlinlang.org/docs/inline-functions.html] exposed to your consumers. When an inline function is marked as public it cannot access any non-public APIs.

internal fun bar() { }

public inline fun foo() {
    // ERROR: public inline functions cannot access non-public APIs.
    bar()
}

To facilitate its usage, Kotlin provides us with the @PublishedApi annotation. This allows us to use non-public APIs in public inline functions since they are treated by the compiler like a public API, thus enforcing any explicit requirements you may have, i.e. when using the Explicit API mode. However, annotating an internal function with @PublishedApi will not make it public to consumer code.

@PublishedApi
internal fun bar() { }

public inline fun foo() {
    bar()
}

// Library consumer
fun consumerCode() {
    // Accessible.
    foo()

    // Remains inaccessible (module internal).
    bar()
}

Opt-in APIs

One issue you might run into when having multiple modules is sharing some functionality between those modules, but not with consumer code.

For example, you might have a foo module that exposes some internal utilities or methods that are meant to be accessed by another module, bar. However, that internal functionality in foo should not be exposed to the consumers as it may be misused.

There are two approaches here:

  1. Mark the declarations in foo as public, making them accessible in bar. Since they effectively public, all you can do is hope your consumers do not use such APIs.
  2. Mark the declarations as internal, rendering them unusable by consumer code but also by your other modules.

None of the two options seems viable.

One important note to make

Were we in a Java project, we could leverage the @RestrictTo annotation to hide public declarations from consumer code, for instance:


@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
public void foo() { }

This would limit foo's usage to other modules that share the same group. The downside of this annotation is that the Kotlin compiler doesn't recognise it.

There is an issue in the JetBrains tracker to bring @RestrictTo support to Kotlin.

@RequiresOptIn to the rescue

If you're using coroutines, you've certainly run into some methods annotated with @InternalCoroutinesApi or @ExperimentalCoroutinesApi. These are there to force the consumer to make a conscious decision when opting to use those APIs.

The @RequiresOptIn allows us to create our own annotations to achieve the same behaviour. Let's consider the case of the library group internal visibility and create our own annotation for that purpose:

@RequiresOptIn(
    level = RequiresOptIn.Level.ERROR,
    message = "This is an internal Foo API that should not be used from outside of the com.foo library group.",
)
public annotation class InternalFooApi

When this done, you can annotate that shared functionality in the foo module:

@InternalFooApi
public fun foo() { }

Then, when you reference something with this annotation, you need to opt in this functionality. There are several ways of doing so, but let's focus enabling this across the consuming module. In the bar module you'd have to add a compiler flag:

freeCompilerArgs += [
        '-Xopt-in=com.foo.InternalFooApi',
]

It's important to note the @RequiresOptIn annotation itself requires you to opt-in into it as it's still experimental.

In order to do so, add the following to the build.gradle of the module holding the annotation:

freeCompilerArgs += [
        '-Xopt-in=kotlin.RequiresOptIn',
]

It's worth to mention this functionality is limited to the Kotlin compiler. It will not be respected in Java, just like the internal keyword. Like previously mentioned, the @JvmSynthentic annotation can be a possible solution until, and if, we get support for the @RestrictTo annotation.

Conclusion

That's it folks! We hope the long read proved useful and that you may even use some of it in your own projects. Best of luck in your @InternalKotlinAdventures.

As always, we hope you liked this article and if you have anything to add, we are available via our Support Channel.

Keep up-to-date with the latest news
Emerce
Capterra
ISO27001