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:
Newsubtype
s of primitives are unboxed in scala 2 (in scala 3 both should be unboxed as expected).- 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.
Newsubtype
s 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.