Type-level programming

Scala · Expert · 18 min

Type-level computation lets libraries like typed SQL, JSON and data-frame APIs catch errors at compile time.

Scala 3 can compute types from types.

Match types are match expressions at the type level:

type Elem[X] = X match
  case String      => Char
  case Array[t]    => t
  case Iterable[t] => t

Elem[List[Int]] reduces to Int. A method returning Elem[X] must be implemented with a matching term-level match so the compiler can relate the two.

Type lambdas [X] =>> Either[String, X] are anonymous type constructors, used when a type class expects a one-hole type like F[_]. Higher-kinded types (trait Functor[F[_]]) abstract over containers themselves; this is how Cats and ZIO define Functor and Monad.

Singleton and literal types treat values as types: 3, "id", true. With scala.compiletime.ops you can do arithmetic on them (type Five = 2 + 3), and constValue brings them back as values.

Tuples are generic heterogeneous lists in Scala 3 (*: and EmptyTuple), with type-level operations like Tuple.Concat, Tuple.Map and Tuple.Size; case classes convert to and from tuples via Mirror. This is the backbone of generic derivation.

Use these sparingly in application code: compile errors become hard to read and compile times grow. They shine in libraries where precise types prevent misuse.

Example

import scala.compiletime.ops.int.*
import scala.compiletime.constValue
type Elem[X] = X match
  case String      => Char
  case Array[t]    => t
  case Iterable[t] => t
def first[X](x: X): Elem[X] = x match
  case s: String      => s.charAt(0)
  case a: Array[t]    => a(0)
  case i: Iterable[t] => i.head
trait Functor[F[_]]:
  extension [A](fa: F[A]) def fmap[B](f: A => B): F[B]
given Functor[[X] =>> Either[String, X]] with
  extension [A](fa: Either[String, A]) def fmap[B](f: A => B) = fa.map(f)
@main def typeLevel(): Unit =
  val c: Char = first("scala")
  val n: Int = first(List(7, 8))
  println((c, n))                                  // (s,7)
  println(constValue[3 * 4])                       // 12
  val t = (1, "a", true)
  val t2: (Int, String, Boolean, Double) = t ++ Tuple1(2.5)
  println(t2)                                      // (1,a,true,2.5)
  val r: Either[String, Int] = Right(20)
  println(r.fmap(_ + 1))                           // Right(21)

Remember

  • Match types compute result types by matching on type arguments
  • Type lambdas [X] =>> F[X] fill one-hole type parameters
  • Literal types plus compiletime.ops do arithmetic at compile time
  • Tuples are type-level lists that power derivation

Watch out for

  • Writing a method with a match-type result but a body that doesn't mirror the type-level match, causing type errors.

Check yourself

  1. Given type Elem[X] = X match { case String => Char; case Iterable[t] => t }, what is Elem[Vector[Double]]?

Sign in to answer, see the explanations, and get this lesson back as a quick review later.

Official docs: docs.scala-lang.org/scala3/reference/new-types/match-types.html