Defining Newtypes

Importing the dependency:

libraryDependencies += "io.monix" %% "newtypes-core" % "0.3.0"

Table of contents:

Newtype

Newtype exposes the base encoding for newtypes over types with no type parameters. It provides no pre-defined builders, such that you need to provide apply or unapply by yourself:

import monix.newtypes._

type EmailAddress = EmailAddress.Type

object EmailAddress extends Newtype[String] {
  def apply(value: String): Option[EmailAddress] =
    if (value.contains("@"))
      Some(unsafeCoerce(value))
    else
      None

  def unapply[A](a: A)(implicit ev: A =:= Type): Some[String] =
    Some(value(ev(a)))
}

It’s more convenient to work with NewtypeWrapped or NewtypeValidated, as shown below.

NewtypeWrapped

We can use NewtypeWrapped for creating newtypes, as simple wrappers (no validation) over types with no type parameters:

import monix.newtypes._

type FirstName = FirstName.Type

object FirstName extends NewtypeWrapped[String]

// Usage:
val fname = FirstName("Alex")

// To coerce into its source type again:
fname.value
//=> val res: String = "Alex"

Note, this is a type-safe alias, aka an “opaque type”, so our FirstName is not seen as a String or vice-versa:

// ERROR — should fail at compile-time
val fname1: FirstName = "Alex"
// ERROR — should fail at compile-time too
val fname2: String = FirstName("Alex")

Pattern matching is also possible:

fname match {
  case FirstName(str) => 
    s"Name: $str"
}

Note that due to type-erasure we are restricting the pattern matching that’s possible. This doesn’t work:

// ERROR — should fail at compile-time
(fname: Any) match {
  case FirstName(_) => "Matches!"
  case _ => "Nope!"  
}

This doesn’t work either:

// ERROR — should fail at compile-time
"Alex" match {
  case FirstName(_) => "Matches!"
  case _ => "Nope!"  
}

And trying to do a regular isInstanceOf checks should trigger at least a Scala warning, due to the type being erased, hopefully you’re working with -Xfatal-warnings:

// ERROR — should fail at compile-time
fname match {
  case ref: FirstName => "Matches!"
}

NewtypeValidated

Use NewtypeValidated for creating newtypes that have extra validation:

import monix.newtypes._

type EmailAddress = EmailAddress.Type

object EmailAddress extends NewtypeValidated[String] {
  def apply(v: String): Either[BuildFailure[Type], Type] =
    if (v.contains("@")) 
      Right(unsafeCoerce(v))
    else 
      Left(BuildFailure("missing @"))
}

We only allow strings with a certain format to be considered valid email addresses:

EmailAddress("[email protected]") match {
  case Right(address) =>
    s"Validated: ${address.value}"
  case Left(e) =>
    s"Error: $e"
}
// res9: String = "Validated: [email protected]"

There are cases in which the validation needs to be bypassed, which can be done via the “unsafe” builder:

val address = EmailAddress.unsafe("[email protected]")

And, we can pattern match it to extract its value:

address match {
  case EmailAddress(str) => s"Matched: $str"
}
// res10: String = "Matched: [email protected]"

Note the same caveats apply for pattern matching:

// ERROR — should fail at compile-time
(address: Any) match {
  case EmailAddress(_) => ()
  case _ => ()
}
// ERROR — should fail at compile-time
"[email protected]" match {
  case EmailAddress(_) => ()
  case _ => ()
}
// ERROR — triggers at least a warning at compile-time
address match {
  case _: EmailAddress => ()
  case _ => ()
}

Deriving type-class instances

We can derive type class instances, with a derive helper available in Newtype:

import cats._
import cats.implicits._
import monix.newtypes._

type FirstName = FirstName.Type

object FirstName extends NewtypeWrapped[String] {
  implicit val eq: Eq[FirstName] = derive
  implicit val show: Show[FirstName] = derive
}

// ...
val fname = FirstName("Alex")

assert(fname.show == "Alex")
assert(Eq[FirstName].eqv(fname, FirstName("Alex"))) 

NewtypeK and NewtypeCovariantK

NewtypeK is for defining newtypes over types with an invariant type parameter.

NewtypeCovariantK inherits from it and is for defining newtypes over types with a covariant type parameter.

import cats._
import cats.implicits._
import monix.newtypes._

type NonEmptyList[A] = NonEmptyList.Type[A]

object NonEmptyList extends NewtypeCovariantK[List] {
  // Builder forces at least one element
  def apply[A](head: A, tail: A*): NonEmptyList[A] =
    unsafeCoerce(head :: tail.toList)

  // Exposes (head, tail)
  def unapply[F[_], A](list: F[A])(
    implicit ev: F[A] =:= NonEmptyList[A]
  ): Some[(A, List[A])] = {
    val l = value(list)
    Some((l.head, l.tail))
  }

  // Utilities specific for NonEmptyList
  implicit final class NelOps[A](val self: NonEmptyList[A]) {
    def head: A = self.value.head
    def tail: List[A] = self.value.tail
  }

  implicit def eq[A: Eq]: Eq[NonEmptyList[A]] =
    derive

  // Deriving type-class instance working on F[_], notice use of deriveK
  implicit val traverse: Traverse[NonEmptyList] =
    deriveK

  // Deriving type-class instance working on F[_], notice use of deriveK
  implicit val monad: Monad[NonEmptyList] =
    deriveK
}

And usage:

val colors = NonEmptyList("Red", "Green", "Blue")
// colors: NonEmptyList.Type[String] = List("Red", "Green", "Blue")

colors.head
// res18: String = "Red"
colors.tail
// res19: List[String] = List("Green", "Blue")

// Pattern matching works
colors match {
  case NonEmptyList(head, tail) => ()
}

// Covariance works
val any: NonEmptyList[Any] = colors
// any: NonEmptyList[Any] = List("Red", "Green", "Blue")

// It can be traversed
NonEmptyList(Option("Red"), Option("Green"), Option("Blue"))
  .sequence
// res21: Option[NonEmptyList.Type[String]] = Some(
//   value = List("Red", "Green", "Blue")
// )

With NewtypeK and NewtypeCovariantK you have to provide the apply, unapply, and other utilities by yourself. Which makes sense, as these are more complex types to deal with.

Newsubtype

Newsubtype exposes the base encoding for new-subtypes over types with no type parameters. It functions exactly the same as Newtype, except as a subtype of the underlying type instead of as an entirely new type.

It provides the same utility classes as Newtype, including NewsubtypeWrapped, NewsubtypeValidated, NewsubtypeK, and NewsubtypeCovariantK.

There are two core benefits of Newsubtype and its variants:

  1. Newsubtypes of primitives are unboxed in scala 2 (in scala 3 both should be unboxed as expected).
  2. There is reduced boilerplate in dealing with the underlying type.

That said, unless you know you need Newsubtype, you’re likely better off living with the extra boilerplate in a production system, as Newsubtype can lead to accidental unwrapping.

Newsubtypes don’t need to declare forwarding methods or reimplement any methods on their underlying types:

import monix.newtypes._

type Level = Level.Type
object Level extends NewsubtypeWrapped[Int]

val myLevel: Level = Level(5)
// myLevel: Level = 5

Thus, we can do things like call + from Int on our new subtype, however this unwraps our result to Int:

val anotherLevel: Int = myLevel + 1
// anotherLevel: Int = 6

The likely desired result type doesn’t work:

// ERROR — should fail at compile-time
val newLevel: Level = myLevel + 1

We would need to re-wrap our results, which could be prohibitively expensive depending on the validation logic on the Newsubtype:

val newLevel: Level = Level(myLevel + 1)
// newLevel: Level = 6

Newsubtype can unwrap in more subtle and potentially dangerous ways. As a simple and contrived example, instances of either of Map[Level, Int] or List[Level] have apply methods that can take our subtype Level but would return dramatically different results. If we were using the Map apply and someone else changed the data type to List, our code would continue to compile but silently produce invalid results. If our Level were a Newtype instead, code using the List apply method but expecting the Map apply would now fail at compile time.

Encoders and Decoders

You can automatically derive encoders based on HasExtractor instances. All newtypes have a HasExtractor instance defined.

Here’s how to automatically derive io.circe.Encoder:

import monix.newtypes._
import io.circe._

implicit def jsonEncoder[T, S](implicit 
  extractor: HasExtractor.Aux[T, S],
  enc: Encoder[S],
): Encoder[T] = {
  (t: T) => enc.apply(extractor.extract(t))
}

And you can also derive decoders, based on HasBuilder instances. Here’s how to automatically derive io.circe.Decoder (validation included):

implicit def jsonDecoder[T, S](implicit 
  builder: HasBuilder.Aux[T, S],
  dec: Decoder[S],
): Decoder[T] = (c: HCursor) => {
  dec.apply(c).flatMap { value =>
    builder.build(value) match {
      case value @ Right(_) => 
        value.asInstanceOf[Either[DecodingFailure, T]]
      case Left(failure) => 
        val msg = failure.message.fold("")(m => s" — $m")
        Left(DecodingFailure(
          s"Invalid ${failure.typeInfo.typeLabel}$msg", 
          c.history
        ))
    }
  }
}

You don’t need to define such encoders and decoders, as they are already defined in the Circe integration.

But you can use HasExtractor and HasBuilder for new integrations.