The original article was published on April 10, 2025.
Hello, I'm Munetoshi Ishikawa, a mobile client developer for the LINE messaging app.
This article is the latest installment of our weekly series "Improving code quality". For more information about the Weekly Report, please see the first article.
Assertive but still worried
The following Rational class represents a fraction, defined as a pair of a signed integer numerator and an unsigned integer denominator. The sign of the fraction itself is determined by the sign of the numerator.
class Rational(val numerator: Int, val denominator: UInt)
Let's say we want to implement a function toIrreducible that returns an irreducible fraction. In the implementation below, we first define a function calculateGcd that returns the greatest common divisor using Euclid's algorithm, and then calculate the irreducible fraction by dividing the numerator and denominator by the greatest common divisor. Here, Kotlin's check function throws an IllegalStateException when its argument is false. In other words, calling toIrreducible when the denominator is 0 will throw an exception. (0U means unsigned 0.)
class Rational(val numerator: Int, val denominator: UInt) {
fun toIrreducible(): Rational {
check(denominator != 0U) { "Denominator must not be zero." }
val gcd = calculateGcd(numerator.absoluteValue.toUInt(), denominator)
return Rational(numerator / gcd.toInt(), denominator / gcd)
}
private tailrec fun calculateGcd(a: UInt, b: UInt): UInt =
if (b == 0U) a else calculateGcd(b, a % b)
}
Are there any improvements to be made to this class?
Eliminate future worries
In this implementation, the validity of the denominator, i.e., whether it is not 0, is checked within toIrreducible. However, if you try to add new functions (plus, minus, etc.) in the future, you will need validation code in all those functions. In the code below, a new function plus is added, and both the receiver and argument denominators are validated. (require is a function that throws an IllegalArgumentException when its argument is false.)
class Rational(val numerator: Int, val denominator: UInt) {
...
infix fun plus(other: Rational): Rational {
check(this.denominator != 0U) { "Left denominator must not be zero." }
require(other.denominator != 0U) { "Right denominator must not be zero." }
...
}
}
If there is an implementation omission in such validation code, it can cause bugs. To improve this, it is better to perform value validation at the time of object creation or state update. If you can ensure that an invalid state (in this example, denominator = 0U) does not exist at the time of creation or update, you can guarantee that all existing objects are valid. In the code below, the denominator is validated to be non-zero in the factory function of, and if the denominator is 0, no instance is created, and null is returned.
class Rational private constructor(val numerator: Int, val denominator: UInt) {
fun toIrreducible(): Rational {
val gcd = calculateGcd(numerator.absoluteValue.toUInt(), denominator)
return Rational(numerator / gcd.toInt(), denominator / gcd)
}
private tailrec fun calculateGcd(a: UInt, b: UInt): UInt =
if (b == 0U) a else calculateGcd(b, a % b)
companion object {
fun of(numerator: Int, denominator: UInt): Rational? =
if (denominator != 0U) Rational(numerator, denominator) else null
}
}
Similarly, when changing the state (such as reassigning properties or adding elements to a collection), you should check the validity of the new state at the time of change. By doing so, you can immediately notify the caller of any operations that would result in an invalid state through return values or other means.
Types as a broom for worries
In the previous example, we defined a factory function that returns null for invalid values. Another possible implementation is to use unchecked exceptions instead of returning null, as shown below. However, this implementation is not as robust as the previous example.
class Rational(val numerator: Int, val denominator: UInt) {
init {
require(denominator != 0U) { "Denominator must not be zero." }
}
...
}
Indeed, this method also guarantees that "as long as an instance exists, it indicates a valid value." However, unlike the previous example, you must ensure that denominator != 0U in all places where the constructor is called. Here, Rational itself does not define how it is used, and you must consider untrusted data sources such as user input. Therefore, checks with if (denominator != 0U) are required at various call sites, but even if there is an implementation omission in the checks, the code will still compile. It is safer to use Kotlin's nullable types or other types that force checks to be performed when using instances.
Of course, in dynamically typed languages where type hints are not available, you may need to use unchecked exceptions as a second-best option. However, in statically typed languages, even if nullable types are not available, you can define and use types such as Optional/Option/Maybe to force the caller to handle failures, achieving a safer implementation.
Note that in the context of explaining "check values at the time of object creation," code using unchecked exceptions may also be presented.
In a nutshell
Value validation should be performed at the time of object creation or state update, and if possible, it is desirable to force the caller to handle failures.
Keywords: validation, data model, type checking