When to use Kotlin's takeIf() and takeUnless() functions
While handy, these functions might not always be appropriate
While not the most widely used functions in the Kotlin standard library, takeIf()
and takeUnless()
can be very handy in making our code easier to read. Lately however, I’ve seen them misused in a way that may introduce some errors. In this post, we’ll learn what those functions are and how not to misuse them.
What do takeIf()
and takeUnless()
do?
In short, takeIf()
can be called on any non-null object (the subject) and takes a predicate as an argument. If the predicate is satisfied (is true), the subject is returned, otherwise null is returned.
Meaning we can replace this:
return if(x.isValid()) x else null
With this:
return x.takeIf { it.isValid() }
takeUnless()
does the opposite. It returns the subject if the predicate is not satisfied, otherwise it returns null.
Replacing this:
return if(!x.isError()) x else null
With this:
return x.takeUnless { it.isError() }
These have both been in the Kotlin Standard Library since 1.1, and I’ve included the code below. Note that the actual implementation has a bit more going on (some annotations and a contract specification) but I removed some lines to reduce clutter and focus on the parts of the implementation we care about for this conversation.
// In Standard.kt
public inline fun <T> T.takeIf(predicate: (T) -> Boolean): T? {
return if (predicate(this)) this else null
}
public inline fun <T> T.takeUnless(predicate: (T) -> Boolean): T? {
return if (!predicate(this)) this else null
}
When not to use takeIf()
and takeUnless()
On the surface, it seems that we could replace if(someCondition) x else null
with x.takeIf { someCondition }
, and if(!someCondition) x else null
with x.takeUnless { someCondition }
, but there is are three subtle differences to be aware of.
Difference 1: Order of Operations
Take this example for instance:
return if(x.isValid()) doWorkWith(x) else null
We might be tempted to write this instead:
return doWorkWith(x).takeIf { x.isValid() }
But by doing so, we’ve possibly introduced a bug. Why? Because we have to execute doWorkWith(x)
before evaluating the predicate. This reverses the order of operations, possibly causing a bug. Suppose doworkWith()
only works on valid input and by calling it before we know our input is valid. This might lead to exceptions.
It is worth noting that this is not a problem if our subject is not an expression, as it is in the example above.
Difference 2: Extra Work
In the if/else
example, we don’t do any parsing at all if x
isn’t valid, but in the takeIf()
versions we always call doWorkWith(x)
, which is extra work in the cases where the predicate is false (and vice versa, for takeUnless()
). Even if our predicate can be safely called at all times, it’s extra work that we don’t need to do.
Difference 3: Side Effects
This is a sub-effect from “extra work”. By doing the extra work when we don’t need to, we run the risk of having our predicate function introduce side effects. I’m not a functional purist so I don’t mind side effects here and there. However, imagine that our predicate logs its work, or maintains an audit log. We’re creating data (a side effect) when in fact, no real work is being consumed (sometimes).
This can be especially true if we don’t have control over the code the predicate calls. Some library provider can change its implementation and we might not realize that we’re creating unwanted side-effects.
When to consider takeIf()
and takeUnless()
Now that we know when not to use these constructs, we can come up with some cases when their use is appropriate.
Case 1: When the subject is not an expression
By calling this on a value we avoid the three problems outlined above (order, extra work, and side-effects). By calling these functions on expressions, we open the door to errors.
Case 2: The predicate is sufficiently complex, making reading it awkward
For a simple example, we could replace this:
return if(x) y else null
With this:
return y.takeIf { x }
It’s entirely subjective whether the takeIf()
version is easier to read. Personally, I find takeIf()
and takeUnless()
more appealing if the predicate is more complex.
Replacing:
return if(evensOnly && x % 2 == 0) x else null
With this:
return x.takeIf { evensOnly && x % 2 == 0 }
Again, it’s subjective. Personally, I find the takeIf()
version easier to read.
Case 3: Another function needs to be called on the subject, conditionally
This can look awkward, especially if we are doing a lot of work in the if
block:
return if(someString.isNotBlank()) {
someMoreWork(someString)
} else {
null
}
So we can replace it with this:
return someString.takeIf { it.isNotBlank() }?.let { someMoreWork(it) }
Again, it’s subjective that the takeIf()
version is any better, but some might find easier to read.
Closing Thoughts
As we’ve seen, the places where we might use takeIf()
and takeUnless()
can be fairly subjective. I do find places in my code to use it, but more often than not I use an if/else
expression instead. I like the fact if
is an expression in Kotlin, and I think that reduces some of the utility of takeIf()
and takeUnless()
. The takeIf()
and takeUnless()
functions aren’t doing anything you can’t do with if/else
, it just makes things easier to read in some cases (which I value highly when writing code).
On the other hand, it feels like calling takeIf()
or takeUnless()
on an expression should at least be a warning in IntelliJ. Maybe this sort of thing doesn’t happen too often, but it’s something I’ve started noticing more and more as I read code in the wild.
Did I miss a case where takeIf()
and takeUnless()
would be appropriate, or a case when they introduce errors? Let me know, I’m always happy to chat and appreciate feedback.