Avoiding Primitive Type Saturation in Kotlin
Modern programming languages have a lot going for them, but a tool is only as good as our capacity of wielding it. So, in this blog post, I’ll explore ways to leverage Kotlin’s incredibly powerful type system to write code that offers greater readability and more runtime safety.
Self-Documenting Code
I’m by no means opposed to writing code comments, but I’m a firm believer that code comments shouldn’t explain something that can be explained by properly naming a function. Taking this idea a step further, we shouldn’t need a code comment for something that the type system itself can explain. Consider the following code:
class Authenticator(private val apiClient: ApiClient) { suspend fun authenticate( password: String, authenticationMethod: String, email: String ) : String = withContext(Dispatchers.IO) { try { apiClient .withMethod(authenticationMethod) .authenticate(email, password) } catch (exception: ApiClientException) { exception.errorMessage } } }
Right off the bat, there are a few problems with it:
-
All the parameters are strings, so one can easily provide them in the wrong order (yes, I put them in that order on purpose).
-
If a fourth parameter were to be provided, it’d take some time for us to figure out the order.
-
If someone changes the order of these around, and if we didn’t use named arguments at the call site, no compiler error would be issued.
-
It’s unclear what a valid value for the authentication method is.
-
There’s no way of knowing what a valid value for the password is.
-
What is the string this method returns?
Some of these issues can be fixed by having test coverage, and some can be remediated by code comments, but the root problem here is overuse of the primitive type String
. Even if this were a public API on a well-documented framework, nothing is preventing the user from calling this passing three empty strings. It’s clear that we need better types to prevent it from happening, and Kotlin has all the tools to help us with this.
Sealed Classes
Kotlin’s sealed classes are a great tool for fighting off primitive saturation. They implement what’s also known as a discriminated union, sum type, or tagged union. Sealed classes are a way of telling the compiler that a type has a finite number of possible values, which allows us to control how such values are created and, later on, makes it possible for the compiler to ensure we’re handling every possible value when using pattern matching.
Let’s improve the example above by implementing a Password
class, which can have two possible values: Valid
or Invalid
. According to our business rules, a Valid
password must be at least six characters long:
sealed class Password { internal abstract val actualPassword: String class Valid internal constructor(override val actualPassword: String) : Password() class Invalid internal constructor(override val actualPassword: String) : Password() override fun toString(): String = actualPassword override fun hashCode() : Int = actualPassword.hashCode() override fun equals(other: Any?): Boolean = when (this) { is Valid -> other is Valid && actualPassword == other.actualPassword is Invalid -> other is Invalid && actualPassword == other.actualPassword } companion object { fun fromString(passwordString: String) : Password = if (passwordString.isBlank() || passwordString.length < 6) Invalid(passwordString) else Valid(passwordString) } } fun String.asPassword() : Password = Password.fromString(this)
Putting this class into its own :models
package guarantees that:
-
Any created instance of
Password
follows our business rules of what constitutes a valid password. -
Consumers of any function receiving
Password
are forced to abide by our rules.
With that out of the way, we can change our signature to expect only the valid password type, Password.Valid
:
class Authenticator(private val apiClient: ApiClient) { suspend fun authenticate( password: Password.Valid, authenticationMethod: String, email: String ) : String = withContext(Dispatchers.IO) { try { apiClient .withMethod(authenticationMethod) .authenticate(email, password.toString()) } catch (exception: ApiClientException) { exception.errorMessage } } }
Not only is that type preventing us from passing an invalid password; it’s also preventing us from mistakenly passing an email in lieu of the password. A big win already!
As a bonus, you might’ve noticed that the underlying authenticate API is still expecting a string. I did this to explicitly show how you can improve the parts of code you have control over without compromising just because some parts you have no control over have a subpar API design.
Enum Classes
Enum classes in Kotlin have the same sum type properties as sealed classes: Given their finite number of possible values, the compiler can ensure we’re treating all possible values and, in turn, offer safety when pattern matching. Some details (like serialization) aside, the difference between enum classes and sealed classes is that enums cannot hold instance-specific values. Enums are like powerful constants. Since the authentication method is just a simple wrapper around a string (i.e. it doesn’t need to wrap any user-provided values), we can use an enum class instead of a sealed class:
enum class AuthenticationMethod(val authCode: String) { Email("dotcom"), Google("dessert_name"), Apple("macintosh"), GitHub("octocat"), Microsoft("clippy") }
The process for validating the email is the same as the password process (except validating emails is a tad more complex, but that’s for another blog post). This is the current state of that initial snippet:
class Authenticator(private val apiClient: ApiClient) { suspend fun authenticate( email: Email.Valid, password: Password.Valid, authenticationMethod: AuthenticationMethod ) : String = withContext(Dispatchers.IO) { try { apiClient .withMethod(authenticationMethod.authCode) .authenticate(email.toString(), password.toString()) } catch (exception: ApiClientException) { exception.errorMessage } } }
Better Return Types
The only thing that still requires head scratching (along with a lot of documentation and possibly checking the implementation itself) is that String
return type. At the moment, the string can represent two things: the token returned from the server (which the app should persist to authenticate the user in the future), or an error message. Right now, this isn’t at all obvious, so a way of improving our modeling would be to use sealed classes!
sealed class AuthenticationResult { data class Success(val apiToken: String) data class Failure(val errorMessage: String) }
The code above is still pretty much returning two strings in the end, but the fact that they’re wrapped in a descriptive type completely removes all doubts about whether the call to authenticate
failed or not! This is what the final product would look like:
class Authenticator(private val apiClient: ApiClient) { suspend fun authenticate( email: Email.Valid, password: Password.Valid, authenticationMethod: AuthenticationMethod ) : AuthenticationResult = withContext(Dispatchers.IO) { try { val apiToken = apiClient .withMethod(authenticationMethod.authCode) .authenticate(email.toString(), password.toString()) AuthenticationResult.Success(apiToken) } catch (exception: ApiClientException) { AuthenticationResult.Failure(exception.errorMessage) } } }
Conclusion
Thinking a bit more about our models can help increase both the runtime safety and the maintainability of our programs by a lot. The aim of this blog post was to show that the Kotlin type system enables us to apply these principles in everyday cases — like validation forms or refactoring of legacy APIs — and not only when solving textbook questions. After all, why should we waste developer time chasing bugs that the compiler could’ve caught?