Kotlin 2.1.0 - Guarded when, what, how?



Kotlin 2.1.0 was released on November 27th and added a number of cool features to the mix. I will be writing on a couple of them that seem like they will be immediately useful. First the highlights!
- New language features in preview: Guard conditions in
when
with a subject, non-localbreak
andcontinue
, and multi-dollar string interpolation. - K2 compiler updates: More flexibility around compiler checks and improvements to the kapt implementation.
- Kotlin Multiplatform: Introduced basic support for Swift export, stable Gradle DSL for compiler options, and more.
- Kotlin/Native: Improved support for
iosArm64
and other updates. - Kotlin/Wasm: Multiple updates, including support for incremental compilation.
- Gradle support: Improved compatibility with newer versions of Gradle and the Android Gradle plugin, along with updates to the Kotlin Gradle plugin API.
- Documentation: Significant improvements to the Kotlin documentation.
Today I will be exploring the first new language feature in preview, guard conditions in `when` with a subject. I'll be honest when I first read it, I said ah, okay cool and moved on without really thinking about use cases that it would be convenient for. Also sometimes I'm not as excited with something when it's just a preview, but in some cases it's almost like peeking at your Christmas presents. So let's take a look at the example!
1sealed interface Animal {
2 data class Cat(val mouseHunter: Boolean) : Animal {
3 fun feedCat() {}
4 }
5
6 data class Dog(val breed: String) : Animal {
7 fun feedDog() {}
8 }
9}
10
11fun feedAnimal(animal: Animal) {
12 when (animal) {
13 // Branch with only the primary condition. Returns `feedDog()` when `Animal` is `Dog`
14 is Animal.Dog -> animal.feedDog()
15 // Branch with both primary and guard conditions. Returns `feedCat()` when `Animal` is `Cat` and is not `mouseHunter`
16 is Animal.Cat if !animal.mouseHunter -> animal.feedCat()
17 // Returns "Unknown animal" if none of the above conditions match
18 else -> println("Unknown animal")
19 }
20}
So what's happening here?
We define a sealed interface Animal that has two classes. A Dog that takes in a breed and has a function defined to feedDog. Appropriate as we are a dog! It would be a shame if we fed the cat here and made the dog watch. It gets more interesting when we define the cat. We take in whether the cat is a mouseHunter. Hmm. Well when we feed the animals we don't want to waste food when if the cat is already eating yummy (to them presumably) mice. But how would we achieve this. Normally in our when expression we would do something like this:
1fun feedAnimal(animal: Animal) {
2 when (animal) {
3 is Animal.Dog -> animal.feedDog()
4 is Animal.Cat -> {
5 if (!animal.mouseHunter) {
6 animal.feedCat()
7 }
8 }
9 else -> println("Unknown animal")
10 }
11}
Ok. Well this isn't terrible. Just check if the animal is a mouseHunter and then if not feed the thing. Well it's easy to see all the context because this example is so small. But even then, when using it the behavior can be sometimes odd and confusing where to put all the behavior. So say we want do some actions presumably.
1sealed interface Animal {
2 data class Cat(val mouseHunter: Boolean) : Animal {
3 fun feedCat() {
4 println("Mmm miow")
5 }
6 }
7 data class Dog(val breed: String) : Animal {
8 fun feedDog() {
9 println("Mmm rough")
10 }
11 }
12}
13
14fun feedAnimal(animal: Animal) {
15 when(animal) {
16 is Animal.Dog -> animal.feedDog()
17 is Animal.Cat -> {
18 if (!animal.mouseHunter) {
19 animal.feedCat()
20 }
21 }
22 else -> println("Unknown animal")
23 }
24}
So now our implementation, prints us out some text when it eats. But if our cat is a mouseHunter. Just nothing happens. Ok. So we could check in the Animal interface also if it's a mouseHunter, now we are checking if we are a mouseHunter when we use it. And checking inside the implementation.
1sealed interface Animal {
2 data class Cat(val mouseHunter: Boolean) : Animal {
3 fun feedCatMaybe() {
4 if (mouseHunter) {
5 println("I don't need your piddly food")
6 } else {
7 println("Mmm miow")
8 }
9 }
10 }
11 data class Dog(val breed: String) : Animal {
12 fun feedDog() {
13 println("Mmm rough")
14 }
15 }
16}
17
18fun feedAnimal(animal: Animal) {
19 when(animal) {
20 is Animal.Dog -> animal.feedDog()
21 is Animal.Cat -> animal.feedCatMaybe()
22 else -> println("Unknown animal")
23 }
24}
Ah, we could put the check inside the interface. So now if we are a mouseHunter we will reject our human charity. If not then we will eat. But now feedCat() as a name doesn't seem right because there is logic and we might not feed them at all. Suppose we want to reduce food stores if we feed them. Now we gotta check in other places.
Let me give you another example that might make it more clear the benefits we can achieve with guarded when.
1sealed interface NotificationEvent {
2 data class MessageNotification(
3 val message: String,
4 val isGroupMessage: Boolean,
5 val isUnread: Boolean
6 ) : NotificationEvent
7
8 data class CallNotification(
9 val callerName: String
10 val isVideoCall: Boolean,
11 val isMissed: Boolean
12 ): NotificationEvent
13}
Let's say we have a notification handler that we want to based on the notification event fire off different types of notifications, sounds, even vibration patterns. So if we take a look we need something for group messages, single messages, missed calls, oh video calls. I'm not going to write out what we would have to do today as it can start to get complicated very quickly.
What if we could treat each case as it's own case within the when expression instead of just only being able to check if it's a message or call and stuffing all the logic together inside each branch.
This is where the guarded when comes in to save the day!
1fun handleNotification(event: NotificationEvent) {
2 when(event) {
3 is NotificationEvent.MessageNotification if event.isGroupMessage && event.isUnread -> {
4 showGroupMessageNotification(event.message)
5 vibrate(pattern = VibrationPattern.GROUP_MESSAGE)
6 playSound(sound = SoundType.GROUP_CHAT)
7 }
8 // handle the regular messages that didn't meet the above criteria
9 is NotificationEvent.MessageNotification -> {
10 showMessageNotification(event.message)
11 vibrate(pattern = VibrationPattern.SINGLE_MESSAGE)
12 playSound(sound = SoundType.SINGLE_MESSAGE)
13 }
14 is NotificationEvent.CallNotification if event.isVideoCall && event.isMissed -> {
15 showMissedVideoCallNotification(event.callerName)
16 vibrate(pattern = VibrationPattern.URGENT)
17 playSound(SoundType.MISSED_VIDEO_CALL)
18 }
19 // handle regular calls that didn't meet the above criteria
20 is NotificationEvent.CallNotification -> {
21 showIncomingCallNotification(event.callerName)
22 vibrate(pattern = VibrationPattern.CALL)
23 playSound(SoundType.INCOMING_CALL)
24 }
25 }
26}
Now instead of getting to each branch and then inside having a bunch of additional logic to work through. Each branch can take care of the other cases based on properties of that particular case. At a glance it makes it much easier to know the different cases, and Kotlin still knows if you haven't handled a particular case. So let's go back to the original example with that in mind!
1fun feedAnimal(animal: Animal) {
2 when (animal) {
3 // Branch with only the primary condition. Returns `feedDog()` when `Animal` is `Dog`
4 is Animal.Dog -> animal.feedDog()
5 // Branch with both primary and guard conditions. Returns `feedCat()` when `Animal` is `Cat` and is not `mouseHunter`
6 is Animal.Cat if animal.mouseHunter -> println("I don't need your piddly food") // or feed a box of mice?
7 is Animal.Cat -> animal.feedCat()
8 // Returns "Unknown animal" if none of the above conditions match
9 else -> println("Unknown animal")
10 }
11}
To get started with checking it out, remember it is in preview for Kotlin 2.1.0 so you will have to enable it explicitly. If you are using gradle then just add:
1kotlin {
2 compilerOptions {
3 freeCompilerArgs.add("-Xwhen-guards")
4 }
5}