12 August 2018
In a previous article, we looked at overloading the invoke()
operator, how to do it, and why we might want to.
I was asked to highlight that you can use the invoke()
operator on a class’s companion object as a way to have a “validation constructor”.
For Part 2 I'd love to see my favorite pattern for invoke: putting it on companion objects and hide the real constructor, so you can get "validation constructors" that return Option or Either if the parameters aren't correct :D pic.twitter.com/LJ1R8rdaQ9
— Paco (@pacoworks) August 10, 2018
First of all, cool! I didn’t realise you could do this, but today I learned and it makes sense:
a companion object is initialized when the corresponding class is loaded (resolved), matching the semantics of a Java static initializer.
from the Kotlin Language documentation page on Object Expressions and Declarations.
An initialized companion object is still an instance of a class (of type MyClass.Companion
) with which you can overload operators.
Let’s see this in action. I haven’t included any third-party libraries, so while the tweet above refers to an Option<User>
I’ll just be using Kotlin’s optional type:
class IceCream private constructor(private val scoops: Int) {
companion object {
operator fun invoke(): IceCream? {
return invoke(0)
}
operator fun invoke(scoops: Int): IceCream? {
return if (scoops < 0) {
null
} else {
IceCream(scoops)
}
}
}
}
And what does it look like to use?
val noIceCream = IceCream()
val someIceCream = IceCream(1)
val moreIceCream = IceCream(2)
// or
val invalidIceCream = IceCream(-1)
I’m not a fan—I would be slightly astonished if I encountered this in a project.
For me, because it looks so much like a constructor invocation, I would expect that these were initializing objects of type IceCream
or crashing at runtime because of some validation error inside the constructor.
Are there any hints that the type of these values is not IceCream
? Yes!
IceCream?
and not IceCream
IceCream
()
with your mouse, you’ll see it’s clickable and will take you to the declaration of the invoke()
operatorYou can use factory classes or even named functions in your companion objects to be more explicit.
This enforces that all objects are well formed, something you'd have to check regardless before or after calling a *real* constructor. If you prefer to give this validation constructor a name and still hide the real constructor, that's good too. I've not seen that much pushback.
— Paco (@pacoworks) August 11, 2018
I spoke with Paco about these factories returning Option<MyClass>
instead of MyClass?
or even throwing an exception in case of invalid parameters and he shared:
“This conversation on Twitter highlighted one improvement we could have in our codebases. By moving to validated constructors, we can avoid a whole set of runtime errors.
“These errors would be highly conditional on the parameters on the constructor, which means difficult to trigger while debugging, and would need to be documented and taken into account when refactoring.
“By simply creating a new validated constructor that returns null or an
Option
(i.e. Arrow/Koptional on a failure), we’re conveying the needs for our object to be constructed, and those will be explicitly mandated across future calls and refactors without requiring users to check the documentation.”
Thanks Paco!