What is (in) a deck

Topics: sealed hierarchies, data classes, immutability

One of the key components in the functional approach to programming we promote is how we model the data. In other words, how we represent the information we care about throughout the execution of our application.

We prefer a immutable representation to one where mutation is available. This main benefit is at the level of reasoning, as it becomes much easier to understand what is going on and potential problems. If instead of modifying data we always transform it into a completely new value, we do not need to care about concurrent accesses. More bluntly, a whole source of potential bugs disappear when using immutability.

This property alone has a profound impact on our data types. Since there is no mutation, the values are stateless. Instead of thinking about modification, for example with person.setName("me"), we think in terms of transformation and copying, person.copy(name = "me"). Functional programmers are usually proud of their anemic domain models, in which operations always exist as transformations of data.

We also strive for a precise representation, which captures every possible invariant (domain rule) in our data. A prime example from the UI world is data which may also be loading or have errors while obtaining. One potential representation is given by

class Result(
  val data: Card?,
  val problem: Throwable?
)

with the additional invariant that at most one of the values should be non-null, and both being null represents a loading state.

This is problematic, though, because there is nothing stopping us from breaking that invariant. A more precise representation capture the three possible states as three different types in a sealed hierarchy,

sealed interface Result {
  data object Loading: Result
  data class Success(val data: Card): Result
  data class Problem(val problem: Throwable): Result
}

Now the compiler guarantees that the right information is present at each point. Furthermore, we gain the ability to use when to check the current state, and the compiler guarantees that we always handle all possible cases.

One nice advantage of using Compose is that it naturally leads to a more immutable representation of state. In the following tasks we focus on the precision of our domain model.

More on functional domain modeling

Improving the domain

More precise type

The given domain model uses a nullable Type in Card. This is because not every card in the Pokémon TCG has a type; this attribute is restricted to Pokémon and basic Energy cards. Your task is to transform the given domain model to capture that invariant.

More precise energies

Even the previous refinement is not completely true. In fact, two types have some special meaning in the game:

  • Dragon may be the type of a Pokémon, but never the type of an Energy. In the game, this manifests as attacks never requiring "dragon energy"; dragon Pokémon always use a combination of other energies.
  • When colorless energy appears in a cost, it may be paid by any type of energy. There are no basic Colorless Energy card, but there are Colorless Pokémon.
KoraidonMiraidonThese cards are of dragon type, but their attacks do not use that energy (since it's forbidden). However, they both use colorless energy.
ChatotSnorlaxThese cards are of colorless type. They are used in every type of deck, since their attack cost can be paid using any energy.

Your task is to refine the given Type to account for these nuances. However, your solution should not be just two or more different types; by using inheritance you can create several subsets of types and share common cases.

Information about evolution

One of the most important features of the Pokémon franchise is that Pokémon evolve, that is, they turn into (stronger) Pokémon as they progress. This is mapped in the TCG as Stage 1 and Stage 2 Pokémon describing which Pokémon they evolve from.

One direction does not imply the other

Every Stage 1 or Stage 2 Pokémon evolves from exactly one Pokémon. However, the converse is not true: a single Pokémon may evolve to more than one Pokémon (or none). For example, Gloom may evolve into Vileplume and Bellossom, with Eevee having record eight different evolutions.

OddishGloomVileplumeBellossom

Your task is to refine the domain model to include this information — using evolvesFrom as the property name is the best choice because it aligns with serialization, but feel free to choose any other name. This in turns creates a domino effect in other files. This is a good thing: it means that the compiler is helping us understand where we need changes to keep everything aligned.

You need to also update the KtorPokemonTcgApi implementation to account for this extra attribute, check the Pokémon TCG API docs for the place where it appears.

kotlinx.serialization

The code uses kotlinx.serialization to transform the JSON returned by the API into Kotlin data classes. For more information, check the introduction and the basic guide.

As an additional task, you can improve the ordering of the deck shown in the right pane by taking evolution into account: evolution chains should appear together. At this point you have two options:

  • Make this ordering "inherent" to the Card type, and modify its Comparable implementation in tcg/tcg.kt;
  • Introduce this new ordering only in the deck/view.kt file, modifying its call to sorted using sortedBy.

Revamping the representation

The way decks are modelled in the Deck type — as a simple list of Cards — makes some operations quite simple (like obtaining the size of the deck) while making some others much more complicated (like understanding how many copies of a certain card are in a deck). As an extra over-arching task you may explore other representations, like using a Map<Card, Int> or a List<Pair<Card, Int>> instead. The important question to answer here is what becomes harder and what easier, and understand the trade-offs in domain modelling.

New search status

The SearchStatus type used in search/viewModel.kt is quite similar to Result describe above. You can take a look at that file and the corresponding view to see how one operates with sealed hierarchies.

To understand how the exhaustiveness helps, add a new data object Unavailable : SearchStatus to this type, which should represent the case in which search cannot be performed because we could not contact the Pokémon TCG API. Your task is to fix all the compiler errors you find. Also, try to think about what would have changed if the code had used else instead of matching on each of the cases separately.