In this article, I will demonstrate how Typed Functional Programming can be used to effectively represent a domain. We will walk through a step-by-step example where different domains are modeled using types and subtypes, and functions are used to transform one domain into another.
Let’s consider the process of making a cake as described in a recipe. The first step is the list of ingredients.
sealed trait Ingredientcase object Flour extends Ingredientcase object BakingPowder extends Ingredientcase object Water extends Ingredient
This list is defined in the recipe, but we cannot be certain that we have all these ingredients in our kitchen. To express this uncertainty, we create another type:
sealed trait NeededIngredient { val ingredient: Ingredient}case class MissingIngredient(ingredient: Ingredient) extends NeededIngredientcase class PresentIngredient(ingredient: Ingredient) extends Ingredient
With these types, we can define the first operation needed to start the cake:
def getIngredients(ingredients: Ingredient*): List[NeededIngredient]
The result is a list of NeededIngredient, which can be either present or missing. While we could have used the standard library’s Option type, it wouldn’t offer the same level of expressivity. In this case, we explicitly want to know which ingredient is missing; a None would have hidden that information.
The next part of the recipe involves mixing those ingredients.
def checkAndMix(ingredients: List[NeededIngredient]): Either[MissingIngredients, Mixture]
The checkAndMix function results in either a successful mixture or a collection of missing ingredients.
case class Mixture(ingredients: List[Ingredient])case class MissingIngredients(ingredients: List[Ingredient])
We could have represented this result differently. Instead of Either, we could have created a custom sealed trait:
sealed trait MixtureResultcase class FullMixture(ingredients: List[Ingredient]) extends MixtureResultcase class PartialMixture(present: List[Ingredient], missing: List[Ingredient]) extends MixtureResult
While this looks elegant, it is actually invalid for our domain: if you don’t have all the ingredients for a cake, you don’t start mixing – that would be a waste!
Either is the correct solution here because it represents a domain disjunction. This means two completely different domains have only one thing in common: they are both valid results of the function, but they cannot exist at the same time.
The final step is baking the mixture.
def bake(mixture: Mixture): Cake
This function only takes a Mixture as input, which is the correct definition from a domain perspective. We want to express that we can only bake after we have successfully mixed the ingredients. The Either type is perfect for this via a map operation:
val neededIngredients = getIngredients(Flour, Water, BakingPowder)val possibleMixture = checkAndMix(neededIngredients)val possibleCake = possibleMixture.map(bake)
The function is applied only if the result is a “Right” value (the mixture exists); otherwise, the “Left” value (the missing ingredients) remains unchanged.
However, a different implementation of the bake function might account for potential baking issues:
def bake(mixture: Mixture): Either[BakingIssue, Cake]
In this case, the result introduces another domain disjunction. If we chain these operations, we end up with nested types:
val possibleCake: Either[MissingIngredients, Either[BakingIssue, Cake]]
Comparing this to our first implementation, the type has become complex and difficult to read. It would be much cleaner to have a type that clearly shows the final result – the cake – alongside any potential problems that occurred during preparation.
val idealCake: Either[OneOfTwo[MissingIngredients, BakingIssue], Cake]
This ideal solution combines the different negative paths into a single type where only one issue can be present at a time. Here is a simple implementation that converts a nested Either into a “flat” one:
sealed trait OneOfTwo[+A, +B]case class First[A](a: A) extends OneOfTwo[A, Nothing]case class Second[B](b: B) extends OneOfTwo[Nothing, B]// convertdef convert[A, B, C](either: Either[A, Either[B, C]]): Either[OneOfTwo[A, B], C] = either.fold( a => Left(First(a)), { case Left(b) => Left(Second(b)) case Right(c) => Right(c) })
This approach keeps our domain disjunctions organized on the left-hand side, leaving the right-hand side clear for our successful result: the cake.