Introduction
This is Jeremy, a backend engineer on the Cloud Consolidated Accounting team. We work on an accounting platform written in Kotlin. This article will introduce something we did with the Kotlin type system in order to improve the type hierarchy without compromising the safety advantages of strict typing.
We’re using Kotlin 2.1; in later versions parts of this may no longer apply.
The problem
Within our product we have two currency types:
- Reporting Currency, the working company for each child company in a multinational conglomerate
- Consolidation Currency, the currency that will be used to present the report of the entire group’s financial situation.
Both of these types are currency amounts, containers holding a number. They also have precision and rounding rules. The same basic operations can be done on each, but they can never be mixed. It would make no sense to add values of two currencies – say, JPY and EUR – together to receive a result in one of them. To perform that addition a currency conversion must be executed and that can’t be done automatically.
Of course the actual currencies for the child and parent companies in a group may also be the same; we don’t need to give this case special treatment.
The safety aspect of this system is that no matter how badly an engineer messes up, they won’t use one class where the other is required. When numbers pass from the child company calculations to group company calculations, the correct currency conversion operations must be performed – there’s no chance of accidentally sending a value straight through.
As we prioritize correct calculations over developer convenience we have separate classes for each currency type. The result of this is some operations have to be implemented on both classes, resulting in code duplication.
So, our goal is:
- Two distinct classes that cannot be cast to each other
- Logic that is the same between both classes can be written once and applied to both
- Results of calculations must remain in the same type as the inputs
Details
We have operations that can be defined on both types, mostly relating to summation and categorization but because of the distinct classes they had to be implemented twice. Kotlin and Java engineers will probably be saying to use generics, but the simple implementation would like this:
abstract class Currency {
fun reconstruct(value: BigDecimal): Currency
fun valueAsBigDecimal: BigDecimal
fun <T: Currency> positiveOrZero(): T {
return if (valueAsBigDecimal() < 0) {
this // this fails because it's not type T
} else {
// Again, this declaration doesn't provide an object of type T
reconstruct(BigDecimal.ZERO)
}
}
}
class ConsolidationCurrency : Currency() {
...
}
class ReportingCurrency : Currency() {
...
}
fun <T: Currency> sumPositive(list: List<T>) : T {
list.map {
it.positiveOrZero()
}.sum()
// doesn't compile
}
The problem with this is the return type being the parent class – there’s nothing but the developer’s vigilance stopping them from casting to the wrong child class. This may fail at runtime but we want to catch problems before then.
Ideally we wanted a type where operations could be defined on a parent class, but child classes could not be mixed. For example:
// Not real code
interface Parent {
fun common(other: Child): Child {
// do something
return something
}
}
class ChildA : Parent() {
fun aStuff() {
}
}
class ChildB : Parent() {
fun bStuff() {
}
}
// This must be possible
val a1 = ChildA()
val a2 = ChildA()
val b1 = ChildB()
val a3 : ChildA = a1.common(a1) // not a cast, the real return type
// These must not be possible
a1.common(b1) // error: a1.common requires a ChildA
b1.common(a1.common(a2)) //error: a1.common returns a ChildA, b1.common requires a ChildB
Our current code solves this the simplest way, along the lines of:
class ChildA {
fun common(other: ChildA) : ChildA {
return process(this.getValue(), other.getValue()) as ChildA
}
}
class ChildB {
// Not an override function, just the same name
fun common(other: ChildB) : ChildB {
return process(this.getValue(), other.getValue()) as ChildB
// exactly the same implementation as ChildA.common
}
}
This is the code duplication mentioned in the introduction. This method is safe and effective but it seemed like something better was also possible.
Implementation
Our solution to this problem uses type definitions like this:
abstract class ParentClass<T : ParentClass<T>> {
operator fun plus(other: T): T {
magic() // TODO
}
}
class ChildA: ParentClass<ChildA> {
}
class ChildB: ParentClass<ChildB> {
}
The key is the recursive type constraint on the parent class. Classes inheriting ParentClass must provide a type parameter that is descended from ParentClass; that is, the descendant class itself. Another descendant would also satisfy the constraint but the code would make no sense.
The key is that we can now define
fun method<T: ParentClass<*>>(a: T, b: T): T
val x: ChildA
val y: ChildA
val z = method(x, y)
I was later informed that this is known as the “recursive generic” pattern, and has existed for over 30 years.
For the first step, this looks roughly like the solution. But it’s not complete yet.
Initializers
Logically we would like to implement something like this:
abstract class ParentClass<T : ParentClass<T>> {
companion object {
// does not compile, because the companion object does not have access to type T
abstract fun construct(x: BigDecimal): T
}
operator fun plus(other: T): T {
construct(this + other)
}
}
However, companion objects don’t work this way. A companion object is a single object, shared by all instances of the class; type parameters belong to instances so do not exist on the class itself.
Based on this, a real companion object that behaves this way is impossible – any method that uses the type parameter must exist on an instance, not on the class itself.
As a workaround, we can do the following:
- Define an interface that holds all the methods we want from the companion object
- Create an abstract value in the parent class of that type. This way, all child classes must have an object that satifies the interface
- In each child class, define a companion object that implements that interface and use it to fill the value.
There’s nothing to stop the child class implementation from creating a new object for each instance, but passing using the companion object is the most straightforward technique. Whatever method is used, we are able to define methods on the parent class that are guaranteed to be available on child classes:
interface ParentHelper<T> {
fun construct(value: BigDecimal): T
}
abstract class ParentClass<T: ParentClass<T>> {
abstract val helper: ParentHelper<T>
abstract fun getRoundedPrice(): BigDecimal
// We have the parts required to define this now
operator fun plus(other: T): T =
helper.construct(getRoundedPrice() + other.getRoundedPrice())
}
class ChildClassA: ParentClass<ChildClassA>() {
companion object Companion : ParentHelper<ChildClassA> {
override fun construct(value: BigDecimal): ChildClassA {
...
}
}
// This is a property on every instance, but its content is the (shared) companion object
override val helper = Companion
...
}
Now we have methods defined on the parent class that correctly return objects in the child classes. As long as this part of the implementation is done correctly a user of this class can’t cross the types without deliberate intent.
Zero
One of the side effects of this technique is that unity values are no longer trivial. We cannot (and do not want) to be able to call ParentClass.zero()
and receive something compatible with all children as that would violate our type safety.
For example, if we wanted a fallback value for a nullable argument:
fun <T: Parent> absoluteDiff(a: T, b: T?) {
val c = b ?: Parent.ZERO // this could never be the right type
return if (a > c)
a - c
else
c - a
}
Asking an instance for a zero-ed version of itself, as in a.compatibleZero()
feels unnatural as the zero is not a property of the instance – although its type is. This can be fixed by modifying the companion helper interface:
interface ParentHelper<T> {
...
fun compatibleZero(): T
}
We can then define our function:
fun <T: Parent> absoluteDiff(a: T, b: T?): T {
val b = a ?: a.companion.compatibleZero()
return if (a > c)
a - c
else
c - a
}
A similar trick is required with lists. An empty list is still a valid list so any method that works on a list will need to receive the type as well:
fun <T: Parent> runningDiff(xs: List<T>, output: KClass<T>): T {
return fold(output.companion.compatibleZero()) { a, b ->
absoluteDiff(a, b)
}
}
If runningDiff is called with a list of ChildB, the compiler will force the output
argument to be ChildB::class
.
Results
This class structure meets all the requirements defined above, to create types that have commonalities at the parent but remain thoroughly separated afterwards. However, the increased complexity outweighed the benefits of the implementation for us so this has been shelved for now. We may reconsider this when if we implement other features down the road that need the same implementation across two currencies.
Conclusion
This is how we attempted to improve our code using Kotlin’s type system. It’s not quite right for us at the moment, but it makes for an interesting study of the Kotlin type system.