LY Corporation Tech Blog

We are promoting the technology and development culture that supports the services of LY Corporation and LY Corporation Group (LINE Plus, LINE Taiwan and LINE Vietnam).

This post is also available in the following languages. Japanese

Improving code quality - Session 31: Same pot properties

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

List of articles on techniques for improving code quality