Kotlin’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?
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
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
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.
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.
It’s often a red flag if we see duplicated code and we generally try to reduce it as much as possible.
When we control all the code, we can massage structures to make this task easier but we don’t always have this flexibility.
This post explores how to remove duplication when two functions share logic but act on unrelated types with no shared interface - using Kotlin and Swift as examples.
Let’s look at a more challenging case.
The Problem
The algorithm itself isn’t important here, but consider the following starting point:
data classInputA(valname:String,valtag:String?)data classInputB(valname:String,valtag:String?)data classOutputprivateconstructor(valname:String,valtag:String){constructor(inputA:InputA,tag:String):this(inputA.name,tag)constructor(inputB:InputB,tag:String):this(inputB.name,tag)}funprocessInputAs(inputs:List<InputA>):List<Output>{returninputs.mapNotNull{it.tag?.let{tag->Output(inputA=it,tag=tag)}}}funprocessInputBs(inputs:List<InputB>):List<Output>{returninputs.mapNotNull{it.tag?.let{tag->Output(inputB=it,tag=tag)}}}
As shown above, both processInputAs and processInputBs are nearly identical.
The issue is that these two functions operate on different input types with no common supertype.
This means we can’t write a simple generic function with a constrained type parameter, because we have no common supertype to constrain against.
So how might we solve this duplication if our types did share a common shape?
When a Shared Constraint Exists
If InputA and InputB did have a common supertype we could trivially create a single function that handles them both.
Unfortunately, in our original framing, InputA and InputB share no common supertype.
This can occur in situations where you don’t own the types as they come from a third-party library or maybe the types are generated with something like OpenAPI.
When I hit this situation, I tend to look at the two functions side by side and highlight the differences.
Reveal The Differences
For these functions the differences are going to be:
The type of the argument
How we access tag (keep in mind the code looks the same but InputA and InputB are distinct types)
How we construct Output.
In the listing below I’ve highlighted these areas with multiple ?s.
Next, we need to teach the function how to read the tag value.
As we only know we have a T we can provide a function that takes a T and returns the String? we expect.
We don’t always need shared supertypes to make algorithms generic.
With a little upfront work teaching the algorithm how to access and create data, we can operate on unrelated types just as effectively.
Both Kotlin and Swift make this especially clean through their support for passing method and initializer references - keeping our higher-order functions readable and expressive.
Most of us are familiar with YAGNI (“You Aren’t Gonna Need It”), that old developer mantra reminding us not to add code or functionality until it’s truly needed.
But if we have a mantra for not adding unnecessary code, it seems only fitting we have one for removing code that’s outlived its usefulness.
That’s where my internal monologue often kicks in: DYSNI - Do Ya Still Need It?
Ruthless Simplification with DYSNI
At the day job, my colleagues Adam and Ellen (aka the only two who foolishly raised their hands when I asked for volunteers to help remove some Objective-C) and I were doing some Objective-C cleanup.
We still have a portion of Objective-C in our codebase, but we’re aiming to remove it.
The team’s Objective-C skills aren’t being exercised and risk atrophying, new hires rarely have experience with it, and its presence complicates adopting modern technologies such as Swift Concurrency.
The first instinct when migrating from Objective-C is usually to just port the code like for like.
There’s often an attempt to make things feel more Swifty, but ultimately the original structure tends to linger.
By asking DYSNI on repeat, we were able to take a pretty gnarly abstraction and simplify it right down.
The Problem
The starting point we had was this lovely structure
The signature of the completion handler was void (^)(NSDictionary *userInfo, BOOL success, NSError *error) (don’t we all just miss the Objective-C syntax?).
The way this was being used is that there was a view controller that held a reference to the current command
The currently executing block would be stored in this ivar until it completed its work and then it was nil‘d out.
The first DYSNI
We started in possibly the worst place but hey that’s how reality is sometimes.
We asked Do Ya Still Need It whilst looking at this hierarchy.
The thinking was that if you have a class with a single method, it could be represented as a single anonymous function.
So instead of holding onto the CommandBlock instance itself we could just hold on to the function reference execute(completion:) with a view to removing the base class at some point.
Why was this a bad starting point?
Because we had to remember Objective-C block syntax… on the second attempt, we got it working and made the following change
Just as we were about to update everything, we paused and asked DYSNI again…
The second DYSNI
Before updating all the code, we wondered if we should check all the callers to see what userInfo is even being used for.
Upon tracing things through for a bit we found that precisely zero callers were using userInfo, so we made the change
That worked well and we kind of assumed that success and error would be used.
Thankfully that assumption didn’t stop us asking DYSNI.
The third DYSNI
We questioned who’s using error so we did the same dance but this time we found one caller was actually reading the error.
Out of curiosity, we followed that caller through and found they didn’t actually do anything with error so
We had to ask is anyone using success or do callers just want to understand when the operation has finished?
In this case success is required so we couldn’t delete it 😢 but never fear because we made things much simpler already.
Thanks to those small DYSNI checks, no future readers of the code would need to ask the same questions we just have.
The fifth DYSNI
At this point we’d spent a fair bit of time in the weeds looking at this function so we zoomed out a bit and asked are these commands even required at all?
What we found was that at some point all uses of CommandA had been removed from the codebase but this was never tidied up.
This unlocked many more opportunities to simplify, for starters we got our delete on and went from
With this simplification done we went back and asked the question we didn’t want to ask but knew we should, do we still need the function signature we spent ages messing around with?
The answer was no we do not - it turns out that we don’t know why there was an instance variable in the first place.
Our best guess is that the original developers assumed they needed to keep a reference to the command whilst it was executing and then nil it out on completion.
In reality, the command is a one shot deal that keeps itself alive until the work is completed then it would naturally go out of scope.
Instead we just new up an instance of the command and invoke it
[CommandB.newexecuteWithCompletion:^(BOOLsuccess){// do some stuff}];
*Controversial use of dot syntax on the new there 👀.
Conclusion
Asking the question “Do Ya Still Need It?” can be a surprisingly powerful tool.
The story above resulted in a diff of 126 insertions(+), 928 deletions(-), which is a great result.
Not only did we reduce line count, but we also encoded our new understanding of the problem into more modern approaches, which will hopefully be easier for future readers to pick up.
Sometimes, when I ask DYSNI, I get the sense people think I’m being lazy or annoying on pull requests but ruthlessly simplifying things down means less work now and less complexity to unpick later.