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.
Retrace your steps
When using I/O such as networks or file systems, you need to convert between data representations in I/O and data representations in code. A typical example is the conversion between externally defined data like Interface Description Language (IDL) or database schemas and model classes defined in code. When using values that mean "state" or "type", you might use enumerations in code.
The following code uses a Map
to convert between database values and enumerators.
enum class AccountType { FREE, PREMIUM, ULTIMATE }
val DB_VALUE_TO_ACCOUNT_TYPE_MAP: Map<Int, AccountType> = mapOf( // or SparseArray
0 to AccountType.FREE,
1 to AccountType.PREMIUM,
2 to AccountType.ULTIMATE
)
val ACCOUNT_TYPE_TO_DB_VALUE_MAP: Map<AccountType, Int> = mapOf( // or EnumMap
AccountType.FREE to 0,
AccountType.PREMIUM to 1,
AccountType.ULTIMATE to 2
)
Is there any problem with this code?
Two parallel roads
This code defines the conversion from database values to enumerations and vice versa separately, leading to the following two issues:
- You have to update both
Map
when specifications change. - You can't guarantee that the two conversions correspond to each other.
To solve this problem, it's better to derive the reverse conversion from the other conversion. Especially if you can use enumerator properties or when
/switch
expressions, you can use them instead of Map
to ensure that all enumerators are covered.
Implementation example 1: Enumerator properties & reverse conversion map
By using enumerator properties, you can ensure that all enumerators have corresponding database values. In Kotlin, you can use the associateBy
function to create a Map
from dbValue
to AccountType
. In many other languages, you can achieve the same by writing functions like map
or loops over enumerators.
enum class AccountType(val dbValue: Int) {
FREE(0),
PREMIUM(1),
ULTIMATE(2);
companion object {
val DB_VALUE_TO_TYPE_MAP: Map<Int, AccountType> =
AccountType.entries.associateBy(AccountType::dbValue)
}
}
The following code shows an example of performing the conversion.
accountType.dbValue // AccountType to DB value
AccountType.DB_VALUE_TO_TYPE_MAP[dbValue] // DB value to AccountType
This implementation is simple, but if AccountType
is used widely, the dbValue
will also be visible widely, which can be problematic. Additionally, if you need to convert between multiple data representations, you will need to define multiple values corresponding to dbValue
, which can lead to confusion. The following implementation of AccountType
has values for multiple conversions, but it is easy to imagine that the class definition will quickly become bloated if the number of conversions increases. In that case, consider applying the next "Implementation example 2".
enum class AccountType(
val dbValue: Int,
val fooApiJsonValue: String,
val barApiJsonValue: String
) {
...
}
Implementation example 2: Implement conversion within individual layers & reverse conversion map
If the code using the conversion is confined within a function, layer, or scope, you can define the conversion within that range. The following code defines the conversion between serverValue
and AccountType
within the scope of AccountTypeNetworkClient
. Here, the conversion from AccountType
to serverValue
uses a when
expression to ensure that all enumerators are covered.
class AccountTypeNetworkClient {
/* snip */
companion object {
private fun AccountType.toServerValue(): String = when (this) {
AccountType.FREE -> "free"
AccountType.PREMIUM -> "premium"
AccountType.ULTIMATE -> "ultimate"
}
private val SERVER_VALUE_TO_TYPE_MAP: Map<String, AccountType> =
AccountType.entries.associateBy { it.toServerValue() }
}
}
Another option is to define classes like Mapper
or Converter
individually. Some platforms make it easy to implement such classes. However, even when implementing Mapper
or Converter
, you should define both the forward and reverse conversion in the same file or class.
Unit tests to ensure injection
Both of the above implementation examples ensure that all enumerators have corresponding values (the enumerators are the domain). However, they do not guarantee that the values are unique (injective). To ensure that the values are unique, you should write tests like the following:
assertEquals(DB_VALUE_TO_TYPE_MAP.size, AccountType.entries.size)
Summary
When performing bidirectional conversion, it's preferable to derive one conversion logic from the other.
Keywords: type conversion
, bidirectional
, single source of truth