The original article was published on June 27, 2024.
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.
Same pot properties
Suppose you are implementing a service that registers location information using "latitude/longitude" or "location ID". This location information data model is represented by a class called GeoLocationPinModel
. By using a sum type, you can ensure that "location information is always indicated by either latitude/longitude or location ID" by type checking. In Kotlin or Java, sum types can be implemented with sealed class
or sealed interface
. (To obtain properties of a subclass of sealed
, downcasting is necessary, but this downcasting is often not a problem. On the other hand, downcasting to anything other than sealed
should be avoided as much as possible.)
The following GeoLocationPinModel
is implemented as a sealed interface
, but the structure of the properties is not appropriate.
sealed interface GeoLocationPinModel {
data class LatLon(
val userId: ULong,
val timestampInMillis: Long,
val comment: String,
val latitudeE6: Long,
val longitudeE6: Long,
) : GeoLocationPinModel
data class Place(
val userId: ULong,
val timestampInMillis: Long,
val comment: String,
val placeId: ULong,
) : GeoLocationPinModel
}
In this data model, downcasting is necessary to obtain common properties (e.g., userId
) between LatLon
and Place
. As a result, the code to obtain common properties becomes cumbersome.
fun caller(pinModel: GeoLocationPinModel) {
val userId: ULong
val creationTimestampInMillis: Long
when (pinModel) {
is GeoLocationPinModel.LatLon -> {
userId = pinModel.userId
creationTimestampInMillis = pinModel.timestampInMillis
}
is GeoLocationPinModel.Place -> {
userId = pinModel.userId
creationTimestampInMillis = pinModel.timestampInMillis
}
}
... // snip
}
Therefore, we attempted to improve it by extracting common properties into the parent class so that they can be obtained without downcasting.
sealed class GeoLocationPinModel(
open val userId: ULong,
open val timestampInMillis: Long,
open val comment: String,
) {
data class LatLon(
override val userId: ULong,
override val timestampInMillis: Long,
override val comment: String,
val latitudeE6: Long,
val longitudeE6: Long,
) : GeoLocationPinModel(userId, timestampInMillis, comment)
data class Place(
override val userId: ULong,
override val timestampInMillis: Long,
override val comment: String,
val placeId: ULong,
) : GeoLocationPinModel(userId, timestampInMillis, comment)
}
fun caller(pinModel: GeoLocationPinModel) {
val userId = pinModel.userId
val creationTimestampInMillis = pinModel.timestampInMillis
... // snip
}
However, there is still room for improvement in this implementation. What could it be?
Extract common properties
In the improved code, if you try to add, change, or delete common properties, you must update all classes of GeoLocationPinModel
, LatLon
, and Place
. Also, the definitions of LatLon
and Place
are cumbersome, and it is not immediately clear what properties they have.
In such cases, extracting common properties outside the sum type can lead to a more understandable structure. In the following implementation, the sealed interface
does not have common properties, and a non-sealed
class holds the common properties. Even when you want to use common properties like userId
, you can fully understand it by looking at the definition of GeoLocationPinModel
.
sealed interface GeoLocation {
data class LatLon(val latitudeE6: Long, val longitudeE6: Long) : GeoLocation
data class Place(val placeId: ULong) : GeoLocation
}
data class GeoLocationPinModel(
val userId: ULong,
val timestampInMillis: Long,
val comment: String,
val location : GeoLocation
)
Separating common properties from the sum type has the following advantages:
- It becomes clear which are common properties and which are type-specific properties
- The amount of code changes when adding, changing, or deleting common properties is reduced
- (In the case of
sealed interface
) By not having common properties in the parent, it can be guaranteed that they are not computed properties
Of course, there are cases where property extraction is unnecessary. This applies when type-specific properties are important, and common properties are merely incidental. Compare the code with and without extraction to determine which is more understandable and easier to handle.
In the case of structural subtyping
In languages with structural subtyping like TypeScript, you can directly obtain common properties as shown below. Therefore, the advantage of extracting common properties is relatively small.
type GeoLocationPinModel =
| {
readonly type: 'latlon';
readonly userId: number;
readonly latitude: number;
readonly longitude: number;
}
| {
readonly type: 'place_id';
readonly userId: number;
readonly placeId: number;
};
const caller = (pinModel: GeoLocationPinModel) => {
// `.userId` is accessible because it's a common property.
const userId = pinModel.userId;
if (pinModel.type === 'place_id') {
// `.placeId` is accessible by type-guard.
const placeId = pinModel.placeId;
}
};
However, there are still two advantages to extracting common properties:
- It becomes clear which are common properties and which are type-specific properties
- The amount of code changes when adding, changing, or deleting common properties is reduced
Even in languages with structural subtyping, it is important to compare the code to understand the merits and demerits of extracting common properties.
In a nutshell
By extracting common properties from sum types, it is possible to create a more understandable structure.
Keywords: sum type
, sealed class
, common property