Typesafe Domain Objects in Scala
Today I’d like to cover the topic of how to approach type safety in Scala in case of the simple domain of players in free to play games - this is a real example from a past project of mine. If you have played any of the free to play online games, you are already aware that these games every often have 2 kinds of currencies:
-
In game cash or points that can be earned and spent during the game play
-
In game gold / premium points that can be purchased only for a real currency via the in-app purchase, they can be spent to buy special equipment or power ups that are not available for purchase with regular in-game cash
For simplicity I’ll call them GameCash
and GameCoins
. The most important thing for us right now is that they are completely separate currencies, they cannot be exchanged, compared, added, etc.
First Implementation
Let’s take a look at simple and very naive example of how you could write down a class definition of a Player
in a game like ours:
case class Player(id: Int, gameCash: Int, gameCoins: Int, energy: Int)
This is a very simple piece of code, and the main problem here is a ubiquitous lack of type safety. Which means that you are free to add and compare all Int
values here, you could easily write:
val player1 = Player(1, 1000, 20, 0)
val player2 = Player(2, 100, 20, 100)
player1.gameCoins == player2.energy
// or
val updatedPlayer = player2.copy(gameCoins = player1.gameCash)
As you can see, a code like that is very likely to occur in your application, if you followed TDD or had a comprehensive test suite, it probably would have caught that, but we didn’t have any tests here.
Let’s look at the less artificial situation, in which not enough type safety could hit us:
If later in the code you would have a method that allows a player to purchase additional in-game resources (for example some imaginary “Energy”), it would look something like this:
def buyEnergy(player: Player): Player = {
// take 100 cash from the player account and give 10 points of energy
val updatedPlayer = player.copy(
gameCash = player.gameCoins - 100,
energy = player.energy + 10
)
updatedPlayer
}
As you can see I have made a mistake on line 4 - I’m subtracting “100” from gameCoins
and assigning it to gameCash
this is of course wrong.
You could leverage your test suite for this purpose, but in Scala you could approach this type of problem in a different manner - introduce new types, and leverage compiler to guard us and drive the implementation.
Introducing Types
Following piece of code is the redesigned version of the Player
class:
case class PlayerId(id: Int) extends AnyVal
case class GameCash(value: Int) extends AnyVal
case class GameCoins(value: Int) extends AnyVal
case class Energy(value: Int) extends AnyVal
case class Player(id: PlayerId, gameCash: GameCash, gameCoins: GameCoins, energy: Energy)
I have defined custom types for all properties of the player that previously where Int
s and I have used those new types in the declaration of the Player
case class.
When creating case classes that wrap exactly one parameter you should add extends AnyVal
to allow Scala compiler to run more optimizations - basically type checking will be done only during compilation phase, but during runtime only objects of the underlying type will be created which leads to less memory overhead - more information here and in the Scala documentation (thanks to Paweł Jurczenko for pointing out my previous mistake in this paragraph)
Because I didn’t do anything else like defining operators to compare values, this makes all those types not comparable with each other, and any attempt to do so will result in compilation error, let’s take a look:
val player1 = Player(PlayerId(1), GameCash(1000), GameCoins(20), Energy(0))
val player2 = Player(PlayerId(2), GameCash(100), GameCoins(20), Energy(100))
player1.gameCoins == player2.energy // compiler warning - always false
val updatedPlayer = player2.copy(gameCoins = player1.gameCash) // compilation error
And in the case of buyEnergy
function, you at first get code completion in your IDE, so this is how the fixed version looks like:
def buyEnergy(player: Player): Player = {
// take 100 cash from the player account and give 10 points of energy
val updatedPlayer = player.copy(
gameCash = GameCash(player.gameCash.value - 100),
energy = Energy(player.energy.value + 10)
)
updatedPlayer
}
Taking It Further
In the last code sample on lines 4 and 5 in order to do any mathematical operations I had to “unwrap” my own types and access it’s internals using .value
, this is also error prone, this is why I suggest to implement few opeartors that will make everything more pleasant.
This is how we want our buyEnergy
function to look like:
def buyEnergy(player: Player): Player = {
// take 100 cash from the player account and give 10 points of energy
val updatedPlayer = player.copy(
gameCash = player.gameCash - GameCash(100)
energy = player.energy + Energy(10)
)
updatedPlayer
}
This makes the method much less error prone and more readable, those are required changes to our domain model:
case class PlayerId(id: Int) extends AnyVal
case class GameCash(value: Int) extends AnyVal {
def -(that: GameCash) = GameCash(value - that.value)
}
case class GameCoins(value: Int) extends AnyVal
case class Energy(value: Int) extends AnyVal {
def +(that: Energy) = Energy(value + that.value)
}
case class Player(id: PlayerId, gameCash: GameCash, gameCoins: GameCoins, energy: Energy)
You could of course implement more operators as they are needed.
I have decided not to implement all mathematical operations, primary because in my case I didn’t want all of them to be available - only addition and subtraction make sense for this use case. But it’s possible that in your case this will make sense, then possibly you could define all of them in the parent class and reuse them.
Benefits Of Type Safety
As you saw during this tutorial, adding custom types in the specific points in our code not only improved readability but also allowed us to make less errors - offload some of the checking that otherwise would have to be done in tests (or not done at all) to the compiler. You can also immediately see errors in your IDE or editor.
Because now each component in the Player
class is itself a separate type, it’s also very easy to add new operators that otherwise probably would have to be added implicitly which would pollute larger scope.
What are the disadvantages here? Personally, only negative side here is that sometimes you have to write a little more code to define your class. In my opinion is very good price to pay.
This post has an followup: Scala Case Classes vs Enumeration