kotlin

Need tiny types to go with your Kotlin? Get inline…

Thoughts on implementing Tiny Types in Kotlin

I’ve recently been involved in building a new application from scratch, using Kotlin, and I suggested to the team that we implement the Tiny Types pattern. Tiny Types are a fix for the code smell dubbed “Primitive Obsession” in Martin Fowler’s Refactoring book (also described nicely at refactoring.guru). In this post I’m not going to talk too much about why you’d want Tiny Types — for that, I suggest you read this article: Big benefits from tiny types: How to make your code’s domain concepts explicit. Instead, I’m going to focus on how you could implement Tiny Types in Kotlin, and in particular why you might want to use inline classes (still experimental in Kotlin 1.3)… and why you might prefer to avoid them for now.

When is a string not a string?

Lots of ecommerce systems have the concept of a Product containing one or more Variants (for example, different size and colour). Here’s a hypothetical method signature in Kotlin:

fun getVariant(productId: String, variantId: String): Variant

With both parameters being String, the compiler won’t stop me doing something silly like mixing up the parameters:

val variantId = "v1"
val productId = "p1"
getVariant(variantId, productId)

Tiny Types to the rescue! I’ll change the function declaration to something like:

fun getVariant(productId: ProductId, variantId: VariantId): Variant

class ProductId(val productId: String)
class VariantId(val variantId: String)
class Variant(val variantId: VariantId,
              val parentProduct: ProductId)

Now the incorrect code won’t even compile:

e: src/test/kotlin/uk/teadd/TinyTypesTest.kt: (10, 20): Type mismatch: inferred type is VariantId but ProductId was expected
e: src/test/kotlin/uk/teadd/TinyTypesTest.kt: (10, 31): Type mismatch: inferred type is ProductId but VariantId was expected

Enriching the ID types

I’d like these ProductId and VariantId types to be comparable naturally using equals, and therefore also to have a sensible hashCode implementation. I might naturally reach for Kotlin’s data classes:

data class ProductId(private val productId: String)
data class VariantId(private val variantId: String)

@Test
fun `should find two product IDs equal`() {
    val productId1 = ProductId("p1")
    val productId2 = ProductId("p1")

    assertEquals(productId1, productId2)
}

Now suppose I want to use these IDs in a string. Let’s define an extension property on the Variant class like this:

val Variant.name get() = "product $parentProduct variant $variantId"

The data class representation of the value is more verbose than I want:

@Test
fun `should print IDs as their string values`() {
    val productId = ProductId("iPad")
    val variantId = VariantId("Black 32GB")

    val variant = getVariant(productId, variantId)

    assertEquals("product iPad variant Black 32GB", variant.name)
}

// fails with message:
// Expected :product iPad variant Black 32GB
// Actual   :product ProductId(productId=iPad) variant VariantId(variantId=Black 32GB)

The way around this is to override toString on our data classes:

data class VariantId(val variantId: String) {
    override fun toString() = variantId
}
data class ProductId(val productId: String) {
    override fun toString() = productId
}

What about performance?

Suppose our Product API handles thousands of requests per second, and we’ve done some profiling and found that the allocation of objects for ProductId, VariantId and various other tiny types is becoming problematic and working the garbage collector too hard.

Enter Kotlin’s inline classes (experimental in Kotlin 1.3).

An inline class must have a single property initialized in the primary constructor. At runtime, instances of the inline class will be represented using this single property.

There are a number of restrictions and caveats listed in the Kotlin docs, and you have to configure the compiler to enable the feature. But for the scenario outlined above, they could help solve the memory problem, since the “wrapper” class is inlined at compile time. We get the best of both worlds – strong typing and no runtime overhead!

So, what’s the catch?

Well, one problem my team discovered was with our preferred mocking library, MockK, which doesn’t currently support mocking methods with inline class types as parameters1, or at least not without a workaround.

@Test
fun `should be able to mock getVariant calls`() {
    val mockRepository = mockk<ProductRepository> {
        every { getVariant(any(), any()) } answers {
            Variant(secondArg(), firstArg())
        }
    }

    val variant = mockRepository.getVariant(ProductId("p1"),
                                            VariantId("v1"))

    assertEquals(VariantId("v1"), variant.variantId)
}

// test execution error:
// io.mockk.MockKException: Failed matching mocking signature for
// SignedCall(retValue=, isRetValueMock=true, retType=class uk.teadd.Variant, self=ProductRepository(#1), method=getVariant-ICtzF0Y(String, String), args=[null, null], invocationStr=ProductRepository(#1).getVariant-ICtzF0Y(null, null))
// left matchers: [any(), any()]

Note the method signature in that error message: method=getVariant-ICtzF0Y(String, String). This is due to the compiler “mangling” the names of functions as explained in the inline class docs.

This can be worked around by explicitly referencing the inline class in the mock setup, but it is rather clumsy:

every { getVariant(ProductId(any()), VariantId(any())) } answers { Variant(VariantId(secondArg()), ProductId(firstArg())) }

In my team’s case, we didn’t actually have any expectation (let alone evidence) of a performance problem, so we simply switched back to using data classes. We hadn’t invested lots of time in this; it was interesting to try it out but at the first sign of complications we were able to revert.

Nevertheless, I’ll be keeping an eye on inline classes, and if/when they move from experimental to GA, or I determine I have a more definite need for them, perhaps they’ll be worth another look.



Image credit: [Original photo: User:Fanghong Derivative work: User:Gnomz007](https://commons.wikimedia.org/wiki/File:Russian-Matroshkanobg.jpg) / CC BY-SA