Strongly Typed Programming – Is it worth it?

Hey everyone,

for the past year or so, I’ve been working on writing what is essentially a Medium-published book about (backend) Kotlin, aimed at facilitating Kotlin adoption in Java-centric enterprises.

One of the things I address are techniques that aren’t actually Kotlin specific, but to which Kotlin lends itself well. However, since I don’t have as much experience applying them to real world projects as I would like to, I’m not sure how much of the attraction is of a “puritanical” nature, and how much of it comes from actual, real world benefits. This is at the core of what I want to ask about in this post.

Everything I talk about is described from the standpoint of a standard service-oriented enterprise – projects where revenue is generated by billable time, team members often rotate, knowledge-transfer is lacking or incomplete, and the application keeps growing beyond its original scope.

The specific subject I want to ask about is what I call “Strongly typed modeling”, but I’ve also encountered the terms Functional Domain Modeling and Designing with Types. I’ll give a very short rundown of what I talk about, so you can browse through quickly without having to read the articles, and link to the content I wrote at the end of each section (I’ll post friend links, so they won’t count towards your limit if you don’t have a Medium subscription). The post ends with the questions I want to ask.

1. Preventing Runtime Errors – Sealed Classes

I start out by demonstrating how sealed classes can help turn a runtime error (your worst enemy) into a compile-time error:

interface Expression data class Const(val number: Double) : Expression data class Sum(val e1: Expression, val e2: Expression) : Expression object NotANumber : Expression // ... // Completely different file, written a long // time ago in a service layer far far away // ... fun simplify(expr: Expression): Expression = when(expr) { is Const -> expr is Sum -> when { simplify(expr.e1) == Const(0.0) -> simplify(expr.e2) simplify(expr.e2) == Const(0.0) -> simplify(expr.e1) else -> expr } is NotANumber -> NotANumber else -> throw IllegalArgumentException("Unknown class ${expr::class}") } 

Ten years after this code was written, when all the original programmers are long dead (read: moved up to management), a business scenario arises in which you need to add a Product class. However (for whatever reason), you miss the simplify function, and end up introducing a runtime error.

This can be prevented by using sealed classes:

sealed interface Expression data class Const(val number: Double) : Expression data class Sum(val e1: Expression, val e2: Expression) : Expression data class Product(val e1: Expression, val e2: Expression) : Expression object NotANumber : Expression // ... // Completely different file, written a long // time ago in a service layer far far away // // This won't compile. // ... fun simplify(expr: Expression): Expression = when(expr) { is Const -> expr is Sum -> when { simplify(expr.e1) == Const(0.0) -> simplify(expr.e2) simplify(expr.e2) == Const(0.0) -> simplify(expr.e1) else -> expr } is NotANumber -> NotANumber } 

This approach prevents a whole class of errors from ever making it to production, and do so provably, independent of test coverage, correct design, or human thoroughness.

The article: The Kotlin Primer – Preventing Runtime Errors (3 min read).

2. Strongly Typed Illegal States

I start by analyzing the following code, which is basically a template for every business rule ever written:

interface CalculationResult fun calculateValue(): CalculationResult = TODO() fun valid(result: CalculationResult): Boolean = TODO() fun format(value: CalculationResult): String = TODO() fun sendResponse(response: String): Unit = TODO() fun execute() { val value: CalculationResult = calculateValue() if(!valid(value)) { throw Exception("Invalid value calculated!") } val response: String = format(value) sendResponse(response) } 

I point out the following:

  • the format function may implicitly rely on the fact that value is valid.
    • 10 years from now, this requirement is long forgotten, and format gets called with a value that was not validated first
    • a new business requirement arises in which a result is only partially validated — for example, parts of the CalculationResult are not needed in every case. Again, format gets called with a value that was not validated in the way it expects.
  • We don’t immediately know which “code paths” can actually be generated by this code – if the result is not valid, an exception gets thrown, which gets handled someplace else. When multiple callers, @ControllerAdvice etc. are involved, finding (and maintaining) the correct path can be difficult and error-prone.

I then demonstrate that this can be solved by modeling error states as data types:

sealed class ValidationResult data class Valid(val result: CalculationResult) : ValidationResult() data class Invalid(val message: String) : ValidationResult() fun validate(value: CalculationResult): ValidationResult fun format(result: ValidationResult): String = when(result) { is Valid -> ... is Invalid -> ... } fun execute() { val value: CalculationResult = calculateValue() val validationResult: ValidationResult = validate(value) val response: String = format(validationResult) sendResponse(response) } 

The key benefits are:

  • Illegal states (in fact all states) are explicit, and represented by types.
  • Function signatures communicate and enforce assumptions (format requires its input to be validated first)
  • The data flow is completely linear. There are no branches, jumps, no catch blocks, no special situations, it’s just calculateValue -> validate -> format -> sendResponse
  • Formatting of all data is done in one place, for all scenarios. Again, no special situations, no alternative ways a response can get sent, no ExceptionHandler, ControllerAdvice etc.
  • Since we use sealed classes, whenever we add a new state (e.g. a PartiallyValidResult), we are immediately told which parts of the code we need to adapt.

Again, this has provably prevented a whole category of errors.

The article: The Kotlin Primer – Strongly Typed Illegal States (4 min read)

3. Strongly Typed Domain Modeling

There’s a lot of content that I don’t want to skip over in this one, so I’ll just give a short description without examples and link directly to the article.

Essentially, it’s about taking this:

enum class UserState { NEW, VERIFICATION_PENDING, VERIFIED } data class User( // Must be nullable - object won't be persisted when // we first create it val id: Long?, val email: String, val password: String, // Must be nullable - only present on // VERIFICATION_PENDING and VERIFIED users val name: String?, // Must be nullable - only present on // VERIFICATION_PENDING and VERIFIED users val address: String?, // Must be nullable - only present on // VERIFIED users val validatedAt: Instant?, val state: UserState ) 

And turning it into this:

sealed interface User { val id: Long? val email: String val password: String } data class NewUser( override val id: Long?, override val email: String, override val password: String, val name: String?, val address: String?, ): User data class PendingUser( override val id: Long?, override val email: String, override val password: String, val name: String, val address: String ): User data class VerifiedUser( override val id: Long?, override val email: String, override val password: String, val name: String, val address: String, val validatedAt: Instant ): User 

The article contains specific examples that demonstrate how this leads to code that is markedly cleaner and safer.

The core idea is that implicit relationships between values (described by the comments in the first version) are made explicit, and therefore enforceable at compile time. This again prevents a whole class of runtime errors from ever happening, removes the need for defensive checks at the beginning of every business method, and many other benefits – I list 7 in total.

The article: The Kotlin Primer – Strongly Typed Domain Modeling (8 min read)

4. Preventing Runtime Errors – Inline Classes

I demonstrate numerous scenarios that lead to runtime errors, and how they can be fixed using inline classes. There are 4 examples in total, I’ll only list 2 to keep things short (yeah, a little late for that, I know).

Before:

typealias FirstName = String typealias LastName = String fun printName(firstname: FirstName, lastname: LastName) = println(""" |First name: $firstname |Last name: $lastname """.trimMargin()) fun main() { val firstname: FirstName = "Peter" val lastname: LastName = "Quinn" // Compiles fine! printName(lastname, firstname) } 

After:

@JvmInline value class FirstName(val value: String) @JvmInline value class LastName(val value: String) fun printName(firstname: FirstName, lastname: LastName) = println(""" |First name: ${firstname.value} |Last name: ${lastname.value} """.trimMargin()) fun main() { val firstname: FirstName = FirstName("Peter") val lastname: LastName = LastName("Quinn") // Doesn't compile! printName(lastname, firstname) } 

Before:

data class Sale( val productName: String, val price: Double ) val sale1 = Sale("Product1", 3.99) // USD // In some completely different part of the codebase val sale2 = Sale("Product1", 88.23) // CZK - oops 

After:

@JvmInline value class PriceCZK(val value: Double) data class Sale( val productName: String, val price: PriceCZK ) val sale1 = Sale("Product1", PriceCZK(3.99)) // Can't make the same mistake again 

The article: The Kotlin Primer – Inline (Value) Classes (4 min read)

Questions

First, if you got this far – thank you, I really appreciate it.

Here are my questions:

  • All of the benefits listed and demonstrated above seem at least ostensible. However, I’m interested if the actual, real-world benefit of implementing this style is all that it promises to be. Is it really that great? Or is it something that just seems great on the surface, but when you implement it, it turns out that it doesn’t deliver?
    • Maybe it does solve the problems it claims to solve, but those problems don’t appear that often in the first place.
    • Or maybe they do, but there is some type of cost associated to adhering to this style of programming, and the cost is such that it’s not actually feasible to do on a real-world project.
    • I’m especially interested in responses backed by actual experience with doing things in this way – what were the actual consequences of shifting to this style of programming?
  • Related to the above – are there any downsides to this approach that I don’t address?

Of course, any other type of feedback related to anything you can think of is much appreciated.

submitted by /u/shadow5827193
[link] [comments]