applyIf and letIf?

I very often find myself doing this:

foo = bar
  .let {
    if (someCondition) {
      it.baz()
    } else {
      it
    }
  }

and similarly with apply:

foo = bar
  .apply {
    if (someCondition) {
      baz()
    }
  }

Wouldn’t it be great if the stdlib had letIf and applyIf?

foo = bar
  .letIf(someCondition) {
    it.baz()
  }

foo = bar
  .applyIf(someCondition) {
    baz()
  }

I realize this can very easily be added to my own projects, but I think there’s a case to have this in the standard library directly, as it is probably a very common use case.

Thoughts?

1 Like
foo = bar
   .takeIf { condition }
  ?.let { it.baz() }
  ?: it
foo = bar
   .takeIf { condition }
  ?.apply { baz() }
2 Likes

@jacek.s.gajek This is one possible alternative way to do this, but I would argue that it is not as readable or concise as my proposed syntax, nor as readable or concise as the original with ifs inside let/apply.

A valid idea, but what if someCondition needs to invoke some property or method of bar? In your original example someCondition ist inside let / apply so it has access to it / this. That is also the ‘spirit’ of takeIf: the subject is in scope, you don’t need to write bar again.

One solution could be that letIf / applyIf take 2 closures, but that isn’t very Kotlin-y (.letIf({ ... }) { ... } :roll_eyes:).

I would do it the way @jacek.s.gajek did or - if it’s more complex / not quite readable at a glance - do it like your original example.

1 Like

Good point @frozenice - I guess if this can’t be used when the condition depends on the receiver, then it may not be worth adding it to the stdlib then.

Just for more context, I find I need this pattern often with Jetpack Compose, where applying modifiers in a chain happens a lot:

    Component(
        modifier = Modifier
            .horizontalScroll(scrollState)
            .wrapContentSize()
            .letIf(isLargeScreen) {
              it.padding(8.dp)
            }
    )
1 Like

I haven’t used Jetpack Compose yet, but isn’t Modifier a builder, i.e. it returns itself and you can set multiple modifications? A let or letIf doesn’t fit there in my opinion, because you’d want to return the builder and not something from your closure, so you can chain further. Your applyIf would fit better there, I think.

I would probably do this with apply / also (you can group multiple conditions in there, too):

Component(
  modifier = Modifier
    .horizontalScroll(scrollState)
    .wrapContentSize()
    .apply {
      if (isLargeScreen) {
        padding(8.dp)
      }

      if (someOtherCondition) {
        other("stuff")
      }
    }
)

Why the “let”? Kotlin has if-expressions instead of if-statements.

So you can:

fun main() {
  var foo: Any? = null
  val bar = object { fun baz() = Unit }
  val someCondition = true

//sampleStart
  foo = if (someCondition) {
      bar.baz()
    } else {
      bar
    }
//sampleEnd
}

EDIT:
Since it seems other people have the same reaction to OPs original idea as me, I figure I’ll reply to myself for “why not use if-expression”.

While the use-case of “foo = …” is solved well by an if-expression, when using a call chain (i.e. when using a fluent API) you may want to place the condition somewhere other than at the assignment. Here’s a more relevant example from OP.

8 Likes

(Sorry for the late reply.)

@frozenice Modifier is used like a builder, but actually it is immutable. In other words, you need to explicitly return the result of each call in the chain, otherwise it will be lost.

Another example I think would be some chain of Flow operators:

val result = someFlow
  .filter { it.name == query }
  .letIf(includeTitle) { 
    it.filter { it.title == query } 
  } 
  .letIf(includeDesc) { 
    it.filter { it.desc == query } 
  } 

In that context, I guess applyIf doesn’t really work, and letIf would be the good fit.

1 Like

Wouldn’t it be cleaner to do?

.filter { it.name == query }
.filter { !includeTitle || it.title == query }
.filter { !includeDesc || it.desc == query }

Or even:

.filter {
    if.name == query
        && (!includeTitle || it.title == query)
        && (!includeDesc || it.desc == query)
}

You can argue your code is a little better for the performance as it checks conditions only once and creates an “execution plan” accordingly.

But generally I agree, it could be sometimes helpful to map a value depending on a condition. But I think the condition would have to be a lambda, not boolean. Providing a boolean there doesn’t seem very Kotlin-ish to me.

1 Like

@broot I guess, but as noted previously, with 2 lambdas, you can’t benefit from trailing lambdas out of parentheses anymore… On the other hand, we could imagine having 2 versions of letIf, one that takes a boolean and one that takes a lambda.

Idk, personally, I’d prefer a boolean–but even more than that, I’d hope any complex chained lambda logic should be factored into a well-named function. Just like you factor out the if-logic.

For example

// ...
Component(
  modifier = Modifier
    .horizontalScroll(scrollState)
    .wrapContentSize()
    .apply {
      if (isLargeScreen) {
        padding(8.dp)
      }

      if (someOtherCondition) {
        other("stuff")
      }
    }
)

Becomes

Component(
  modifier = Modifier
    .horizontalScroll(scrollState)
    .wrapContentSize()
    .withSizeAdjustedPadding()
    .applyOtherCondition()
)

This is the same thing you expect to do when you’re cleaning up a function that does more than one thing–when that “other thing” is deciding what padding, deciding which file to load, or deciding some other condition, the if-block may get factored into its own function.

This example:

val result = someFlow
  .filter { it.name == query }
  .letIf(includeTitle) { 
    it.filter { it.title == query } 
  } 
  .letIf(includeDesc) { 
    it.filter { it.desc == query } 
  } 

might be refactored to

val result = someFlow
  .filter { it.name == query }
  .populateTextFields() // or maybe "addIncludedFields()"?
  .onEach { functionResponsibleForConditionalLogic(it) } // <-- instead of making them sequence functions, this is an option.

There may still be a use for dedicated letIf but I’d still expect long chains of functional calls to be extracted into functions. Once you’re dealing with the smaller footprint, it makes it easy to implement without a letIf.

1 Like

Well, we still can put the second lambda out of parentheses. Assuming we really want to do this, because it may look somehow strange.

I agree, we should generally avoid having two lambdas, but this is not entirely wrong and it is used even in the stdlib. For example groupBy() with separate key selector and value transformer; or Result.fold() where we can map both the success and failure.

I personally don’t do this very often, but sometimes I create such functions with two lambdas. If there is asymmetry between lambdas, so one of them is considered generally smaller, lighter or “initial”, then I place it as the first. Then depending on the case I use different styles when invoking them, again depending on symmetry and sizes of lambdas. Some examples:

users.associateBy({ it.id }, { it.name })

file.letIf({ it.isRelative }) {
    it.resolveToAbsoluteFile()
}

result.fold(
    onSuccess = {
        ...
    },
    onFailure = {
        ...
    },
)

Again, I don’t do this very often and usually it is better to avoid this, for example by chaining multiple higher-order functions, but sometimes it is useful.

Anyway, of course having two overloaded versions: with lambda and boolean; is even better.

2 Likes

The problem is what is the return type of such a function? By your example it can be the type of bar or the return type of bar.baz(). There is no guarantee in general that the types are at all related so the return type may be just Any. That alone I think justifies that it should not be in the standard library because it could be a source of confusion.

But that doesn’t keep you from adding it to your own project or build a utility library. I have these in my project:

inline fun <R, T : R> T.transformIf(condition: Boolean, transform: (T) -> R) =
    if (condition) transform(this) else this

inline fun <R, T : R> T.transformUnless(condition: Boolean, transform: (T) -> R) =
    transformIf(!condition, transform)

but looking at the uses for it I only use it one place. And that is something like this in my dependency injection code to conditionally wrap an object with a logging wrapper:

        DefaultStoreFactory().transformIf(IS_DEBUG) { LoggingStoreFactory(it, logger = instance()) }

It only works in this case because the two types have a common interface.

With your Jetpack Compose example, it would be better to define specific functions rather than more general purpose ones where return type is an issue. For example:

inline fun Modifier.thenIf(condition: Boolean, modification: Modifier.() -> Modifier) =
    if(condition) modification() else this

inline fun Modifier.thenIf(condition: Boolean, modifier: Modifier) =
    if(condition) this.then(modifier) else this
1 Like

Yes, but isn’t that problem pretty common when using generics and type inference in general? Some examples, starting with a little silly ones:

listOf(1, "a")

if (true) 1 else "a"

mapOf(1 to 1).getOrElse(1) { "a" }

listOf(1, 2, 3).ifEmpty { listOf("a", "b", "c") }

Result.success(1).recover { "a" }

Result.success(1).fold({ 1 }, { "a" })

sequence {
    yield(1)
    yield("a")
}

None of these will even show us a warning that we use types inconsistently. It is assumed we want the compiler to find the closest common supertype.

getOrElse() is a pretty similar case to letIf(), because there is a hidden condition (if exists) and then it either returns the value from the receiver or one generated by our lambda. Same for ifEmpty() and recover().

It’s a very good point @dalewking!

I imagined its signature would be:

inline fun <T> T.letIf(condition: Boolean, block: (T) -> T): T {
  return if (condition) block(this) else this
}

But let’s signature is not to return T, but R:

inline fun <T, R> T.let(block: (T) -> R): R {
    return block(this)
}

Having let and letIf have this difference would probably be inconsistent and cause confusion…

I like the tranformIf name for this. :+1:

This issue doesn’t apply (sic) to applyIf though - they’d both return T and be consistent.