For those who need to get straight into the code with explanations as comments, please feel free to jump to:
https://scastie.scala-lang.org/afsalthaj/4rTuhrx6Tw6wohdyaP73bg/2
Others, read on!
Type-driven development
In type-driven development, it is often necessary to distinguish between different interpretations of the same underlying type.
For example, for the underlying type Int, there are multiple possible Monoids:
- Sum → empty = 0
- Product → empty = 1
Even if you don’t know what a Monoid is yet, the classic way to distinguish between these interpretations is to create a newtype.
A newtype is a zero-cost specialization of the underlying type.
It behaves like the original type but carries different semantics.
Existential Types
trait Foo {
type Type
def get: Type
}
object X {
val foo: Foo =
new Foo {
override type Type = Int
override def get: Type = 1
}
val int: Int = foo.get
// Error:
// found: foo.Type
// required: Int
}
val int1: foo.Type = foo.get // compiles
val int2: Int = foo.get // does not compile
Initial Approach
trait NewType[A] {
type Type
def wrap(a: A): Type
def unwrap(a: Type): A
}
def newType[A]: NewType[A] =
new NewType[A] {
type Type = A
override def wrap(a: A): Type = a
override def unwrap(a: Type): A = a
}
Example: Multiplication Monoid
val Mult: NewType[Int] = newType[Int]
type Mult = Mult.Type
implicit val multiplyingMonoid =
new Monoid[Mult] {
override def empty: Mult = Mult.wrap(1)
override def combine(x: Mult, y: Mult): Mult =
Mult.wrap(Mult.unwrap(x) * Mult.unwrap(y))
}
Improving with Subtyping
trait NewType[A] {
type Type <: A
def wrap(a: A): Type
def unwrap(a: Type): A
}
Example: Sum Monoid
val Sum: NewType[Int] = newType[Int]
type Sum = Sum.Type
implicit val summingMonoid =
new Monoid[Sum] {
override def empty: Sum = Sum.wrap(0)
override def combine(x: Sum, y: Sum): Sum =
Sum.wrap(x + y)
}
Ergonomics
trait NewType[A] {
type Type <: A
def apply(a: A): Type
}
def newType[A]: NewType[A] =
new NewType[A] {
type Type = A
override def apply(a: A): Type = a
}
val Sum: NewType[Int] = newType[Int]
type Sum = Sum.Type
implicit val summingMonoid =
new Monoid[Sum] {
override def empty: Sum = Sum(0)
override def combine(x: Sum, y: Sum): Sum =
Sum(x + y)
}
Higher-Kinded Support
trait NewType[A] {
type Type <: A
def apply(a: A): Type
def toF[F[_]](fa: F[A]): F[Type]
def fromF[F[_]](fa: F[Type]): F[A]
}
def newType[A]: NewType[A] =
new NewType[A] {
type Type = A
override def apply(a: A): Type = a
override def toF[F[_]](fa: F[A]): F[Type] = fa
override def fromF[F[_]](fa: F[Type]): F[A] = fa
}
val Mult: NewType[Int] = newType[Int]
type Mult = Mult.Type
Example: Lists
val list1: List[Mult] = List(1, 2, 3).map(Mult(_))
val list2: List[Mult] =
Mult.toF(List(1, 2, 3))
Typeclass Reuse
trait Eq[A] {
def eqv(x: A, y: A): Boolean
}
Manual:
implicit val eqMultManual: Eq[Mult] =
new Eq[Mult] {
override def eqv(x: Mult, y: Mult): Boolean = x == y
}
Derived:
implicit val eqInt: Eq[Int] = ???
implicit val eqMult: Eq[Mult] =
Mult.toF(eqInt)
Reverse Conversion
implicit val eqMult: Eq[Mult] = ???
implicit val eqInt: Eq[Int] =
Mult.fromF(eqMult)
Variance Tricks
If contravariant:
trait Eq[-A]
If covariant:
trait CovariantTypeclass[+A]
Final Thoughts
This is not the final version — better abstractions exist and are evolving.
Credits:
- John De Goes
- Leigh Perry
Further reading: https://francistoth.github.io/2020/04/11/newtypes.html
Summary
- Newtypes enable zero-cost abstraction
- Separate semantics from representation
- Improve type safety without runtime overhead