Building a constrained domain model with Phantom Types

- 8 mins

When designing a domain model, I attempt to ensure that domain objects go through well defined state transitions and that consuming code only receive objects in a valid, expected state (and of course, can only induce a transition to other valid states). It’s possible to complete this approach in Java, but Scala makes it substantially easier. The language’s type system offers a feature called Phantom Types that may be used to lightly mark a class’ valid states in a typesafe way, restricting consuming code to respect the constraints embedded in the types. Let’s play a game to get a better sense of the feature’s capabilities.

Let’s play some cards

Let’s say your you want to build a library to aid you in writing card games. You’ve decided to use Scala to build this library, and you want to use the full range of the language’s type system and ecosystem to make your logic correct and tractable.

We’ll start by describing the high-level objects we want:

A reasonable first draft of your class hierarchy might look like the following:

sealed trait Suit
object Suits {
  case object Heart extends Suit
  case object Spade extends Suit
  case object Club extends Suit
  case object Diamond extends Suit
}

sealed trait CardType
sealed class NonFaceCard(val value: Int) extends CardType
case object Ten extends NonFaceCard(10) // ten is a special card for certain games.


object NonFaceCard {
  def apply(n: Int) = new NonFaceCard(n)
  def unapply(n: Int): Option[NonFaceCard] =
    if (n >= 2 && n < 10) Some(NonFaceCard(n)) else None
}

sealed trait FaceCard extends CardType
case object Ace extends FaceCard
case object King extends FaceCard
case object Queen extends FaceCard
case object Jack extends FaceCard

case class Card(suite: Suit, cardType: CardType)

trait Deck {
    def isEmpty: Boolean
    def size: Int
    def shuffle: Deck
    def contains(card: Card): Boolean
}

case class StandardDeck private (private val cards: Seq[Card]) extends Deck {...}

object Deck {
  import scalaz.State
  import Suits._

  private val suits = Seq(Heart, Club, Spade, Diamond)
  private val faces: Seq[CardType] = Seq(Ace, King, Queen, Jack)

  def make: Deck = {
    val nonface: Seq[CardType] = 2 until 11 map {
      case n if n < 10 => NonFaceCard(n)
      case _           => Ten
    }

    val cards = for {
      cardType <- nonface ++ faces
      suit <- suits
    } yield Card(suit, cardType)

    require(cards.size == 52)

    StandardDeck(cards)
  }

  def draw(n: Int = 1): State[Deck, Traversable[Card]] = State {...}
}

So we’ve distinguished between face and non-face cards, created a trait to allow some flexibility in defining decks, and provided a convenience function for creating a full standard deck of cards.

Restricting domain behavior

With our first draft done, let’s think about Deck’s various states:

We could expand this to include Empty for a fully depleted deck, or include representations for intermediate states, but this should do for now.

We want methods that operate on our domain objects to be aware of these states, and ensure that they only accept a deck in an expected state.

Traditional Object-oriented programming might call for a class hierarchy such as:

interface Deck {...}
abstract class StandardDeck implements Deck {}
class FreshDeck extends StandardDeck {...}
class ShuffledDeck extends StandardDeck {...}
class DrawnDeck extends StandardDeck {...}

and classes that use decks of a particular state would accept a specific subclass as an argument

class Game {
    DrawnDeck play(FreshDeck freshDeck){...}
}

This approach works reasonably well as a first approximation, and if that’s the extent of what your language allows you’ll make it work. It satisfies the demand for domain objects being aware of object states while restricting those methods to receive only those states that they expect.

However, in pursuit of a modeling solution we’ve added more cruft than is strictly necessary. Since the deck’s behavior barely changes while transitioning states, a lightweight solution that merely marks the states would be preferable. Phantom types accommodates this need.

Let’s see how Deck might be modified to use Phantom types as markers for its acceptable states.

sealed trait DeckState

sealed trait Fresh extends DeckState
sealed trait Shuffled extends DeckState
sealed trait Drawn extends Shuffled

case class StandardDeck[Status <: DeckState] private(private val cards: Seq[Card]) extends Deck {...}

We’ll start by creating a sealed trait hierarchy and annotating StandardDeck with a simple upper type bound, then we’ll modify some methods to accommodate the new constraint:

object Deck {
  import scalaz.State
  import Suits._

  def make: Deck[Fresh] = {...}
  def draw(n: Int = 1): State[Deck[Shuffled], Traversable[Card]] = State {...}
}

/**
 * Marker trait of phantom types for denoting game state
 */
sealed trait MatchState

sealed trait Bidding extends MatchState
sealed trait ReDrawing extends MatchState
sealed trait Playing extends ReDrawing
sealed trait PostMatch extends MatchState

case class Match[MS <: MatchState] private (
    deck: Deck[Shuffled],
    teams: ((Team, Option[Bid]), (Team, Option[Bid])),
    trump: Option[Suite] = None,
    private val tricks: Seq[Trick] = Seq.empty) {}

We always want to start-off with a fresh Deck (for now), so we’ll prevent users from initializing a deck in an alternative state.

Additionally, draw may now only operate on a Shuffled or Drawn deck, and can produce a Shuffled or Drawn deck (see trait hierarchy). It’s a really clean approach, and coupled with scala’s copy constructor for case classes, it’s verbosity-free.

In my experience this setup works really well.

Crisson Jno-Charles

Crisson Jno-Charles

Fullstack Software Engineer specializing in Scala, JavaScript, and Java development

comments powered by Disqus
rss facebook twitter github youtube mail spotify instagram linkedin google pinterest medium vimeo