Kotlin Gotchas: Why Your ?.let Sometimes Fails to Compile
08 Nov 2025Kotlin’s let is a great tool for writing expressive code, but I’ve noticed it can introduce subtle fragility when used the wrong way - especially alongside certain compiler features.
Let’s start with a question - should this code compile?
Types
class Example {
val title: String? = null
}
data class Id(val value: String)
Usage
val example = Example()
example.title?.let {
Id(example.title)
}
At first glance, this should compile - and it does. But we can make small, seemingly harmless tweaks that suddenly break it.
Failing to Compile
The first breaking change would be to make title a computed property
class Example {
- val title: String? = null
+ val title: String? get() = null
}
With this change, we get the following error:
Smart cast to ‘String’ is impossible, because ‘title’ is a property that has an open or custom getter.
This error rather cunningly suggests another change that also causes it to fail.
- class Example {
- val title: String? = null
+ open class Example {
+ open val title: String? = null
}
One final change I can think of that breaks for the same underlying reason but is achieved in a different way is to declare Example in a different module from the usage code.
This gives the error
Smart cast to ‘String’ is impossible, because ‘title’ is a public API property declared in different module.
So what’s going on here?
Failing to Smart Cast
We’ve seen Smart cast mentioned in both errors but what does that mean?
A smart cast is when the Kotlin compiler automatically treats a variable as a non-null or a more specific type after it’s checked - but only if it can guarantee the value won’t change in the meantime.
In the original working code, the compiler can see that Example.title is declared as val and cannot be reassigned.
So inside the scope of the ?.let the compiler is able to prove that the value cannot change.
All these breaking changes are just different ways of preventing the compiler from making that guarantee.
There’s another subtle language feature that allows us to write this code without realising the mistake.
Ignoring Closure Arguments
Kotlin allows you to silently ignore closure arguments. This contrasts with languages like Swift, which require you to explicitly mark when you’re ignoring one. For example in Swift we cannot implicitly ignore closure arguments so the compiler would force us to make this change
- example.title?.let {
+ example.title?.let { _ ->
Id(example.title)
}
Thinking about this actually points to the root issue, we should use the argument passed to the closure rather than doing the example.title access again.
Recommendation
Now that we understand why, the fix should be clearer.
Instead of relying on a Smart cast and ignoring the lambda argument, we should just use the closure argument it directly:
This means our call site would change like this
example.title?.let {
- Id(example.title)
+ Id(it)
}
Personally, I would go a step further and favour the more concise call site:
- example.title?.let {
- Id(it)
- }
+ example.title?.let(::Id)
This is an example of point-free style.
Using Point Free Style
Not everyone is a fan of method references because :: looks odd and scary at first.
Once you get used to it, it’s really handy for writing super concise code.
It’s subtle but the second line here packs a punch.
example.title?.let { Id(it) }
example.title?.let(::Id)
In essence we are taking the data from let and plumbing it straight into the Id constructor.
In the first line we are doing this manually by providing a closure and then invoking the Id constructor.
In the second line we are just composing these functions together.
It’s subtle, especially in such a short example.
With the first line, I have to mentally parse the closure, verify how it is used, and ensure it isn’t modified before being passed to Id.
With the second line I don’t have to do any of that - I just know that the let will feed the value straight into Id.
Conclusion
Keeping in mind that the original code listing worked, we could arguably just not worry about any of this. I personally think the end code is simpler and more descriptive about the intent but that can be debated. Also the original code has the issue that unrelated changes can start breaking things, which is something that I think we should always try to avoid. This is definitely one of those changes that I would suggest in a pull request but feel guilty about not providing a thorough explanation of the reasoning. This post gives me extended notes I can point to when explaining the change.