Skip to content
A classy syntax for Scala type classes
Branch: master
Clone or download
Fetching latest commit…
Cannot retrieve the latest commit at this time.
Permalink
Type Name Latest commit message Commit time
Failed to load latest commit information.
project
src/main
tests/src
.gitignore
README.md
build.sbt

README.md

Dandy: a classy Scala syntax for type classes

Type classes are only incidentally tractable in Scala. We have just enough low-level facilities to define type classes and instances, but the language doesn't provide any syntax to express these concepts at a higher level and without so much boilerplate.

Why do type classes need first-class syntax?

Consider the following example. Here we define a type class Foo that requires its members to be also in Show. In Haskell this is trivially accomplished by way of type class inheritance:

class Show a => Foo a where
  foo :: a -> [Char]
  foo a = "foo: " ++ (show a)

instance Foo [Char] where

main = putStrLn $ foo "hello"
-- foo: hello

How can this be replicated in Scala? Tediously!

import cats.Show
// import cats.Show

class Foo1[A:Show] {
  def foo1(a: A): String = s"foo1: ${Show[A].show(a)}"
}
// defined class Foo1

implicit def anyFoo1[A:Show] = new Foo1[A]
// anyFoo1: [A](implicit evidence$1: cats.Show[A])Foo1[A]

def foo1[A:Foo1](a: A) = implicitly[Foo1[A]].foo1(a)
// foo1: [A](a: A)(implicit evidence$1: Foo1[A])String

{
  import cats.implicits._
  println(foo1("hello"))
}
// foo1: hello

That wasn't too bad, but what happens when we need to override the default implementation of foo1? This isn't so pretty:

class IntFoo1 extends Foo1[Int] {
  override def foo1(a: Int) = s"an int: -> $a <-"
}
// <console>:15: error: could not find implicit value for evidence parameter of type cats.Show[Int]
//        class IntFoo1 extends Foo1[Int] {
//                              ^

We're missing the :Show implicit... Which can be resolved with yet more boilerplate:

class IntFoo1(implicit S: Show[Int]) extends Foo1[Int] {
  override def foo1(a: Int) = s"an int: -> $a <-"
}
// defined class IntFoo1

This is a blatant violation of the DRY principle: each new instance of Foo1[A] will need to regurgitate the need for a Show[A]. This is too much typing!

A reduction in boilerplate

Dandy includes a macro annotation - @typeclass - that generates a type class definition based on the annotated structure. This is easily explained using an example. Here we're going to define Foo2[A] just as above, then proceed to also add an instance Foo2[Int] with as little boilerplate as possible. We will also define a catch-all instance Foo2[A].

// plz fix https://github.com/tpolecat/tut/issues/62
// :paste mode would be hella nice here
object foo2Example {
  import dandy._

  // context bounds on `A` require that a `Show[A]` exist for all instances of
  // `Foo2[A]` defined here or elsewhere
  @typeclass class Foo2[A:Show] {
    def foo2(a: A): String
  }
  object Foo2 {
    // define instance `Show[Int]`
    instance[Int] {
      // `Show[Int]` is implicitly available in this scope
      def foo2(a: Int) = s"an int: -> ${Show[Int].show(a)} <-"
    }

    // define a catch-all instance
    def instance[A] {
      def foo2(a: A) = s"something else: => ${Show[A].show(a)} <="
    }
  }

  import cats.implicits._ // we will need `Show` instances
  println(implicitly[Foo2[Int]].foo2(42))
  println(implicitly[Foo2[String]].foo2("42"))
}
// defined object foo2Example

foo2Example
// an int: -> 42 <-
// something else: => 42 <=
// res3: foo2Example.type = foo2Example$@40981dff

Latent instantiation

Unsealed type classes allow users of a library to create new instances anywhere and everywhere. There isn't a universally accepted syntax for this; all of the following have been spotted in commercial and FOSS code:

object foo3Example {
  trait Foo3[A] { /* ... */ }
  implicit object one extends Foo3[String]
  implicit val two = new Foo3[String] {}
  implicit val three: Foo3[String] = new Foo3[String] {}
  implicit def four[A] = new Foo3[A] {}
  implicit def five[A]: Foo3[A] = new Foo3[A] {}
  // ... is there more? there's always room for moar...
}
// defined object foo3Example

foo3Example
// res4: foo3Example.type = foo3Example$@6bfe6f73

Dandy provides a cleaner syntax for creating type class instances outside of the type class companion. First, let's define the type class:

object foo4 {
  import dandy._
  @typeclass class Foo4[A:Show] {
    def foo4(a: A): String
  }
}
// defined object foo4

Next, we define some instances outside of the companion:

import foo4._
// import foo4._

import cats.implicits._
// import cats.implicits._

implicit val foo4Int = Foo4.instantiate[Int] {
  def foo4(i: Int) = Show[Int].show(i)
}
// foo4Int: foo4.Foo4[Int] = $anon$1@75071c60

println(implicitly[Foo4[Int]].foo4(42))
// 42

This works for any types; let's define one on the spot:

case class Person(name: String)
// defined class Person

implicit object showPerson extends Show[Person] {
  def show(p: Person) = s"a person named '${p.name}'"
}
// defined object showPerson

implicit val foo4Person = Foo4.instantiate[Person] {
  def foo4(p: Person) = Show[Person].show(p)
}
// foo4Person: foo4.Foo4[Person] = $anon$1@11377e3f

println(implicitly[Foo4[Person]].foo4(Person(name = "Albert Einstein")))
// a person named 'Albert Einstein'

We can also instantiate catch-all instances:

implicit def anyFoo[A:Show] = Foo4.instantiate[A] {
  def foo4(a: A) = s"something: ${Show[A].show(a)}"
}
// anyFoo: [A](implicit evidence$1: cats.Show[A])foo4.Foo4[A]

case class N(n: Int)
// defined class N

implicit object showN extends Show[N] {
  def show(n: N) = n.toString
}
// defined object showN

println(implicitly[Foo4[N]].foo4(N(42)))
// something: N(42)

Where do you want to go today?

Dandy is a work in progress. Its ideology is similar in some ways to simulacrum and typeclassic, but...

  • Dandy sets its sights first and foremost on reducing boilerplate to the bare minimum. Simulacrum and Typeclassic offer a few other features which Dandy currently classifies as non-goals, although these will be easy to add if necessary.
  • The code does some potentially dodgy things. For example, the generated companion's instantiate method is itself a def macro generated by the @typeclass macro annotation... whoa.
  • Dandy's design and implementation likely suffer from an extension of the author's shortsightedness in terms of how people normally define and use type classes.
  • The macros make assumptions about AST structure that aren't enforced by means other than scala.MatchError. This aspect will be improved if the code proceeds beyond its current purely experimental stage.
  • It's possible that no one in Scala land gives a cack about Haskell-style type class syntax, or type class inheritance. ¯\_(ツ)_/¯
You can’t perform that action at this time.