Skip to content

Kotlin Primer

Operators and Expressions

We won't go into detail on all operators and expressions, but a few in particular can help get you started.

Operators

Most of Java's operators are present in Kotlin, but many are backed by functions that you can override. We'll go over a few here that work differently than in Java.

Warning

I strongly recommend that you only ever override a few, such as get, set, and in. These are useful to override when you define your own collections or want to provide multi-value indexing into a data structure. Overriding others can cause confusion for readers.

Operator Description
in, !in Calls contains() to test if something is in (or not in) a collection
is, !is Tests if an instance is (or is not) of a certain type
as, as? Casts an instance to another type. as? is safe, returning a null if it cannot be cast. as will throw an exception
== Compares the value of two expressions, evaluating to objects and using their equals() functions
=== Compares the actual references of two expressions, i.e. do they point to the same object
x[...] Indexes x using its get or set function (depending on if being used to access or change values)
x..y Creates an inclusive Range. Can be used in a for loop (rarely used) or range check
x..<y Creates a Range, inclusive of x and exclusive of y

Control Expressions

The most important control expressions you'll use are if and when. You generally won't directly use for, as you'll use functions such as forEach() that are defined on various data structures.

if works exactly as it does in Java, with the difference being it's an expression in Kotlin. You can assign values to the result of the if expression:

fun main() {
    val x = 10
    val y = 
        if (x < 10) {
            "Small"
        } else {
            "Large"
        }
    println(y)
}

Note

Kotlin doesn't have the "ternary operator" x ? y : z as defined in Java. Instead, you must use an if expression. Often, if the if expression is very short and would have been written as a ternary expression in Java, we one-line it and remove the curly braces:

fun main() {
    val x = 10
    val y = if (x < 10) "Small" else "Large"
    println(y)
}

The when expression is similar to Java's switch, but:

  • The "cases" in when do not fall through to the next case.
  • Kotlin applies smart-casting when available
  • when allows arbitrary boolean expressions for cases

A few examples:

fun main() {
    val x = 42
    val result =
        when (x) {
            10 -> "Low"
            42 -> "The answer"
            100 -> "High"
            else -> "I really don't care"
        }
    println(result)
}

That's a very direct, value-testing version. when is much more flexible when you don't specify a value, and instead, use boolean expressions for each case.

fun main() {
    val x = 42
    val result =
        when {
            x < 42 -> "Low"
            x == 42 -> "The answer"
            else -> "High"
        }
    println(result)
}

If you use when as an expression or with , it must be exhaustive. All possibilities must be covered. If you use it as a statement, such as

fun main() {
    val x = 42
    when {
        x < 42 -> { println("Low") }
        x == 42 -> { println("The answer") }
    }
}

You don't need to be exhaustive.

Sealed Interfaces

We'll often be using when with sealed interfaces, which is an incredibly cool feature of Kotlin.

Normally, when you define an interface, whether it's in Java or Kotlin, anyone who has access to that interface can create a class that implements it. There's no way in your code to know or control the full set of possible implementations.

A sealed interface locks down implementations to the "module" in which the sealed interface was defined. A module is a single, separately-compiled compilation group, defining a library or application, and modules can depend on other modules during compilation.

By locking down implementations to only inside the same module that defined the sealed interface, we not only prevent other implementations, we can know all implementations of that interface. This allows us to make when exhaustive across those implementations.

Let's start simple:

sealed interface Animal

sealed interface Mammal: Animal
sealed interface Fish: Animal

class Cat: Mammal
class Dog: Mammal

class Koi: Fish
class Humuhumunukunukuapuaa: Fish

fun main() {
    val mammal: Animal = Humuhumunukunukuapuaa()
    val result =
        when(mammal) {
            is Mammal -> "Mammal"
            is Fish -> "Fishy fish"
        }
    println(result)
}

We define sealed interfaces to describe an Animal, which is either (and only) a Mammal or a Fish. Our when allows us to be exhaustive just by specifying is Animal or is Fish. We know that the only possible extensions of Animal are defined in our module. We can refine these checks however we want:

sealed interface Animal

sealed interface Mammal: Animal
sealed interface Fish: Animal

class Cat: Mammal
class Dog: Mammal

class Koi: Fish
class Humuhumunukunukuapuaa: Fish

fun main() {
    val mammal: Animal = Humuhumunukunukuapuaa()
    val result =
        when(mammal) {
            is Mammal -> "Mammal"
            is Humuhumunukunukuapuaa -> "Humu!"
            is Koi -> "Koi!"
        }
    println(result)
}

This is still exhaustive. We have complete control.

So, why is this important?

First, there are plenty of times when defining an API where you really don't intend or want anyone outside your module to extend types. sealed types prevent that.

Second, if we didn't have sealed interface (or sealed class, which is similar), Kotlin will tell us we're not exhaustive and must add an else for other possible types that might be defined.

interface Animal
interface Mammal: Animal
interface Fish: Animal
class Cat: Mammal
class Dog: Mammal
class Koi: Fish
class Humuhumunukunukuapuaa: Fish

fun main() {
    val mammal: Animal = Humuhumunukunukuapuaa()
    val result =
        when(mammal) {
            is Mammal -> "Mammal"
            is Fish -> "Fishy fish"
            else -> "Unknown"
        }
    println(result)
}

Let's say later on that we add a new type, Bird. Our existing code will keep working. That may seem nice, but that makes it really easy to miss things we need to change when adding that Bird.