Expressing Relationships Between Nullable Parameters In Kotlin
Every time we declare a pair of nullable parameters:
fun plotOnMap(
location: LatLng?,
accuracyMeters: Double?
)
we allow 4 different combinations of parameter values:
accuracyMeters
|
|||
---|---|---|---|
null
|
Non-null
|
||
location
|
null
|
✅ | ✅ |
Non-null
|
✅ | ✅ |
Sometimes we know extra information about these parameters that makes one or more of these combinations impossible. We can write working code that accepts and ignores these invalid combinations1, but that code will be prone to accidental misuse and misinterpretation in the future. This post demonstrates how to use Kotlin’s type system to eliminate these risks.
Example 1: “Either both parameters are non-null or both parameters are null”
Original function
fun plotOnMap(
location: LatLng?,
accuracyMeters: Double?
)
Function context
We’re plotting a user’s location on a map. If we know the user’s location, we must always include a circle representing location accuracy. If we don’t know the user’s location, we plot nothing.
Valid parameter combinations
2 out of 4 combinations of parameter values are valid:
accuracyMeters
|
|||
---|---|---|---|
null
|
Non-null
|
||
location
|
null
|
✅ | 🚫 |
Non-null
|
🚫 | ✅ |
Improved function
We can express this relationship in our code by introducing a wrapper type:
data class LocationData(
val location: LatLng,
val accuracyMeters: Double
)
and updating our function signature to receive a nullable instance of that type:
fun plotOnMap(locationData: LocationData?)
Example 2: “If parameter one is null, so is parameter two”
This is an asymmetric version of example 1.
Original function
fun plotOnMap(
location: LatLng?,
accuracyMeters: Double?
)
Function context
We’re plotting a user’s location on a map. If we know the user’s location, we include a circle representing location accuracy if that accuracy is available. If we don’t know the user’s location, we plot nothing.
Valid parameter combinations
3 out of 4 combinations of parameter values are valid:
accuracyMeters
|
|||
---|---|---|---|
null
|
Non-null
|
||
location
|
null
|
✅ | 🚫 |
Non-null
|
✅ | ✅ |
Improved function
We can express this relationship in our code by introducing a wrapper type (note that accuracyMeters
is nullable this time):
data class LocationData(
val location: LatLng,
val accuracyMeters: Double?
)
and updating our function signature to receive a nullable instance of that type:
fun plotOnMap(locationData: LocationData?)
Example 3: “Exactly one parameter must be non-null”
Original function
data class NewCreditCard(/*...*/)
data class SavedCreditCard(/*...*/)
fun chargeOrder(
newCreditCard: NewCreditCard?,
savedCreditCard: SavedCreditCard?
)
Function context
We’re placing an online order. An order should be charged to exactly one credit card.
Valid parameter combinations
2 out of 4 combinations of parameter values are valid:
savedCreditCard
|
|||
---|---|---|---|
null
|
Non-null
|
||
newCreditCard
|
null
|
🚫 | ✅ |
Non-null
|
✅ | 🚫 |
Improved function
We can express this relationship in our code by introducing a sealed type:
sealed class PaymentMethod {
data class NewCreditCard(/*...*/) : PaymentMethod()
data class SavedCreditCard(/*...*/) : PaymentMethod()
}
and updating our function signature to receive a non-null
instance of that sealed type:
fun chargeOrder(paymentMethod: PaymentMethod)
In this example we’ve managed to remove nullability entirely!
Example 4: “At most one parameter is non-null”
Original function
data class NewBankAccount(/*...*/)
data class NewCreditCard(/*...*/)
fun chargeOrder(
newBankAccount: NewBankAccount?,
newCreditCard: NewCreditCard?
)
Function context
We’re placing an online order. An order can be charged to a new bank account or a new credit card. If the user provides neither of these new payment methods, the order is charged to the user’s default saved payment method.
Valid parameter combinations
3 out of 4 combinations of parameter values are valid:
savedCreditCard
|
|||
---|---|---|---|
null
|
Non-null
|
||
newCreditCard
|
null
|
✅ | ✅ |
Non-null
|
✅ | 🚫 |
Improved function
We can express this relationship in our code by introducing a sealed type as in example 3:
sealed class NewPaymentMethod {
data class NewBankAccount(/*...*/) : NewPaymentMethod()
data class NewCreditCard(/*...*/) : NewPaymentMethod()
}
and updating our function signature to receive a nullable
instance of that sealed type:
fun chargeOrder(newPaymentMethod: NewPaymentMethod?)
Summary
Use Kotlin’s type system to make illegal parameter combinations impossible. You’ll thank yourself later.
-
Typically such code will deliberately include no-ops or theoretically throw a
NullPointerException
. ↩