Skip to main content

Algebraic Data Type (ADT)

Algebraic Data Type (ADT)

Define Data in Companion Object

When defining an ADT, define the data in the companion object.

So instead of writing ADT like this,

// Don't
sealed trait MyNumber

final case class SomeNumber(n: Int) extends MyNumber
case object NoNumber extends MyNumber

do this

// Do
sealed trait MyNumber

object MyNumber {
final case class SomeNumber(n: Int) extends MyNumber
case object NoNumber extends MyNumber
}

With this, naming conflict in data can be avoided and the companion object can be a namespace so that it is easy to access all the available data constructors in each ADT.

sealed trait MyNumber
object MyNumber {
final case class SomeNumber(n: Int) extends MyNumber
case object NoNumber extends MyNumber
}

sealed trait AnotherNumber
object AnotherNumber {
final case class SomeNumber(n: Int) extends MyNumber
case object NoNumber extends MyNumber
}

number match {
case MyNumber.SomeNumber(n) =>
// ...
case MyNumber.NoNumber =>
// ...
}
anotherNumber match {
case AnotherNumber.SomeNumber(n) =>
// ...
case AnotherNumber.NoNumber =>
// ...
}
sealed trait ValidationError
object ValidationError {
final case class MissingField(name: String) extends ValidationError

final case class InvalidId(id: Long) extends ValidationError
}

Since MissingField belongs to ValidationError, it can be used as ValidationError.MissingField so no need to repeat Error in its data name like MissingFieldError. It's the same in InvalidId.

Use Constructor Methods

sealed trait MyNumber

object MyNumber {
final case class SomeNumber(n: Int) extends MyNumber
final case class AnotherNumber(n: Int) extends MyNumber
case object NoNumber extends MyNumber

def someNumber(n: Int): MyNumber = SomeNumber(n)
def anotherNumber(n: Int): MyNumber = AnotherNumber(n)
def noNumber: MyNumber = NoNumber
}

To save time to create all constructor methods, use IDE's auto-complete templates. For IntelliJ IDEA, try this one at this blog post. The link to download can be found at https://blog.kevinlee.io/2019/10/01/save-your-time-on-algebraic-data-type-creation-in-scala/#get-the-template-settings (The demo video of the template).

Why?

There can be unwanted types created if data constructors are used. This is an issue with Scala as there is no better way to keep the data hidden and not expose as a type.

val ns = List(MyNumber.SomeNumber(1), MyNumber.SomeNumber(5))
// ns: List[MyNumber.SomeNumber] = List(SomeNumber(n = 1), SomeNumber(n = 5))

As you can see, you've got List[MyNumber.SomeNumber] instead of List[MyNumber]


If you mix more than one data class, you get some weirdly inferred type like this.

val ns2 = List(MyNumber.SomeNumber(1), MyNumber.AnotherNumber(5))
// ns2: List[Product with MyNumber with Serializable] = List(
// SomeNumber(n = 1),
// AnotherNumber(n = 5)
// )

As you can see, it's not List[MyNumber] but Product with MyNumber with Serializable. This is because Scala compiler infers the common type of MyNumber.SomeNumber and MyNumber.AnotherNumber. Since case class is a Product and also Serializable, these are the common types of the both case classes along with MyNumber.


To avoid all these issues, you can use the constructor methods like this.

// Use constructor methods
val ns3 = List(MyNumber.someNumber(1), MyNumber.someNumber(5), MyNumber.noNumber)
// ns3: List[MyNumber] = List(SomeNumber(n = 1), SomeNumber(n = 5), NoNumber)

Now the type is List[MyNumber] as you wanted.


For, Option and Either use data constructors provided by FP libraries like Scalaz and Cats.

More about Option and Either: