« Using Kotlin’s Contract APIs for Smarter Helper Functions

The Problem With Our Existing Functions

The following snippet represents a few examples of the kinds of helper functions we had in an old file.

1val Any?.isNull: Boolean get() = this == null
2val Any?.isNotNull: Boolean get() = !this.isNull
3val Any?.isNullOrEmpty: Boolean
4 get() =
5 when (this) {
6 null -> true
7 is Collection<*> -> this.isEmpty()
8 is CharSequence -> this.isEmpty()
9 else -> false
10 }
11
12val Any?.isNotNullOrEmpty: Boolean get() = !this.isNullOrEmpty

While it’s pretty clear what these helpers are tying to do, there are some limitations with these implementations that maybe aren’t readily apparent until we actually use them.

1fun firstExample() {
2 val name: String? = null
3 if (name.isNull) return
4 Log.i("anisham", "name is ${name?.length} characters long")
5 //compiler error
6 //Only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type String?s
7}

In this snippet, we return if name is null, but after this check, the compiler still can’t guarantee that name is non-null. No smartcasting kicks in to help us.

1fun secondExample() {
2 val names: List<String>? = null
3 if (names.isNullOrEmpty) return
4
5 Log.i("anisham", "names is ${names?.size} characters long")
6 // compiler error
7 //Only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type List<String>?
8}

Even though we’ve already checked that the names variable is not null, the smartcast fails.

1fun thirdExample() {
2 val names: List<String>? = null
3 if (names.isNotNullOrEmpty) {
4 Log.i("anisham", "names is ${names?.size} characters long")
5 //compiler error
6 //Only safe (?.) or non-null asserted (!!.) calls are allowed on a nullable receiver of type List<String>?
7 }
8}

And again, smartcasting doesn’t work after the usage of our isNotNullOrEmpty helper extension.

In all of these cases, our helpers fail to leverage the full power of the Kotlin compiler. Developers are then forced to perform additional null checks — even when we’ve already used our extensions.

How Can Kotlin’s Contract APIs Help?

If we review the ContractBuilder interface, the public APIs direct us to what kinds of contract effects we can define

  1. callsInPlace() — Specifies that the function parameter lambda is invoked in place.
  2. returns() — Specifies that a function will either return successfully or return with a specific value
  3. returnsNotNull() — Specifies that a function will return normally with any value that is not null

Updating Our Helper Functions to Leverage Kotlin Contracts

1@OptIn(ExperimentalContracts::class)
2fun Any?.isNull(): Boolean {
3 contract {
4 returns(false) implies (this@isNull != null)
5 }
6 return this == null
7}
8
9@OptIn(ExperimentalContracts::class)
10fun Any?.isNullOrEmpty(): Boolean {
11 contract {
12 returns(false) implies (this@isNullOrEmpty != null)
13 }
14 return when (this) {
15 null -> true
16 is Collection<*> -> this.isEmpty()
17 is CharSequence -> this.isEmpty()
18 else -> false
19 }
20}
21
22@OptIn(ExperimentalContracts::class)
23fun Any?.isNotNullOrEmpty(): Boolean {
24 contract {
25 returns(true) implies (this@isNotNullOrEmpty != null)
26 }
27 return !this.isNullOrEmpty()
28}

The first thing we must do is convert it from an extension property to an extension method. This is because contracts cannot be applied to a property — only to functions and methods.

We make a call to the contract() function as the first line in our method body. Within the lambda passed to contract() we are specifying two things:

  1. a return value constraint
  2. what that return value implies

In this example, if isNull() returns false, it implies that the receiver object is not null. With that contract in place, if isNull() returns false, subsequent usage of the receiver will not require null-safe accessors.

Contracts can only be understood by the compiler when applied in a called method.

1fun firstExample() {
2 val name: String? = null
3 if (name.isNull()) return
4 Log.i("anisham", "name is ${name.length} characters long")
5}
6
7fun secondExample() {
8 val names: List<String>? = null
9 if (names.isNullOrEmpty()) return
10
11 println("")
12 Log.i("anisham", "names is ${names.size} characters long")
13}
14
15fun thirdExample() {
16 val names: List<String>? = null
17 if (names.isNotNullOrEmpty()) {
18 Log.i("anisham", "names is ${names.size} characters long")
19 }
20
21}

After adding the contract to the method, our smartcast succeeds — eliminating the need for us to perform additional null checks after the call

Examples From The Standard Library

You may already be using contracts without knowing it. They are being added to functions from the Kotlin Standard Library to help improve the type inference with each release.

requireNotNull()

1fun main() {
2 val name: String? = null
3 requireNotNull(name)
4
5 println("name is ${name.length} characters long")
6}

The requireNotNull() function will throw an exception if the passed name variable is null . After the function returns, we can reference name as if it was a non-null type.

1public inline fun <T : Any> requireNotNull(value: T?): T {
2 contract {
3 returns() implies (value != null)
4 }
5 return requireNotNull(value) { "Required value was null." }
6}

isNullOrEmpty()

1/**
2 * Returns `true` if this nullable char sequence is either `null` or empty.
3 *
4 * @sample samples.text.Strings.stringIsNullOrEmpty
5 */
6@kotlin.internal.InlineOnly
7public inline fun CharSequence?.isNullOrEmpty(): Boolean {
8 contract {
9 returns(false) implies (this@isNullOrEmpty != null)
10 }
11
12 return this == null || this.length == 0
13}
14/**
15 * Returns `true` if this nullable collection is either null or empty.
16 * @sample samples.collections.Collections.Collections.collectionIsNullOrEmpty
17 */
18@SinceKotlin("1.3")
19@kotlin.internal.InlineOnly
20public inline fun <T> Collection<T>?.isNullOrEmpty(): Boolean {
21 contract {
22 returns(false) implies (this@isNullOrEmpty != null)
23 }
24
25 return this == null || this.isEmpty()
26}

Opting-In to the Contracts Feature

  1. You can add the @ExperimentalContracts annotation to your functions that use a contract. If that annotation is used, then any callers of the annotated function will need to themselves opt-in to the Contracts feature.
1@ExperimentalContracts
2fun <T> Collection<T>?.isNotNullOrEmpty(): Boolean { ... }
3
4@ExperimentalContracts
5fun main() {
6 listOf("").isNotNullOrEmpty()
7}
  1. Additionally, you can use the @OptIn(ExperimentalContracts::class) annotation to opt-in. If this method is used, then callers of the annotated function will not need to be annotated. However, using the @OptIn annotation also requires the following compiler argument be used -Xopt-in=kotlin.RequiresOptIn.
1@OptIn(ExperimentalContracts::class)
2fun <T> Collection<T>?.isNotNullOrEmpty(): Boolean { ... }
3
4fun main() {
5 listOf("").isNotNullOrEmpty()
6}

For full code please visit:

https://github.com/anishakd4/KotlinContractsAPIDemo