TL;DR
  • Union Types (|): One of several types, Int | String
  • Intersection Types (&): Satisfies all types, A & B
  • Opaque Types: Type safety without runtime overhead
  • Match Types: Type-level pattern matching
  • Scala 3-only features for more powerful type expression

Target Audience: Scala developers who understand generics and variance Prerequisites: Type parameters, type bounds, variance

Scala 3 provides an even more powerful and expressive type system. This document covers Scala 3’s new type features.

Why Do We Need the Advanced Type System?#

Expressing “this function accepts only Int or String” with Any loses type safety, while using Either adds wrapper boilerplate. Union Types, Opaque Types, Match Types, and other advanced features remove these limitations, letting you precisely express domain rules at the type level.

Prerequisites: To understand this document, you should be familiar with:

Difficulty: ⭐⭐⭐⭐ (Advanced) - Includes Scala 3-only features

Union Types (|)#

Union Types represent one of several types. You can work directly with values without wrappers, making them more concise than Either.

// Scala 3 only
def process(input: Int | String): String = input match
  case i: Int    => s"Number: $i"
  case s: String => s"String: $s"

process(42)        // "Number: 42"
process("hello")   // "String: hello"

// Multiple types
type JsonValue = String | Int | Boolean | Null
def toJson(value: JsonValue): String = value match
  case s: String  => s"\"$s\""
  case i: Int     => i.toString
  case b: Boolean => b.toString
  case null       => "null"

Comparison with Either

// Either: Requires explicit Left/Right wrapper
def divideEither(a: Int, b: Int): Either[String, Int] =
  if b == 0 then Left("Cannot divide by zero") else Right(a / b)

// Union: Return value directly without wrapper
def divideUnion(a: Int, b: Int): Int | String =
  if b == 0 then "Cannot divide by zero" else a / b
Key Points
  • Union Type represents one of several types in form A | B
  • Unlike Either, works directly with values without wrappers
  • Use pattern matching to distinguish and handle types

Intersection Types (&)#

Intersection Types satisfy multiple types simultaneously. Useful when an object must implement multiple traits at once.

trait Printable:
  def print(): String

trait Serializable:
  def serialize(): Array[Byte]

// Type that is both Printable and Serializable
def process(obj: Printable & Serializable): Unit =
  println(obj.print())
  val bytes = obj.serialize()

// Implementation
class Document(content: String) extends Printable, Serializable:
  def print(): String = content
  def serialize(): Array[Byte] = content.getBytes

process(Document("Hello"))

Structural Type Combination

type Named = { def name: String }
type Aged = { def age: Int }

def describe(obj: Named & Aged): String =
  s"${obj.name}, ${obj.age} years old"
Key Points
  • Intersection Type satisfies all types in form A & B
  • Used when an object must implement multiple traits simultaneously
  • Can be combined with structural types for flexible type definitions

Opaque Types#

Opaque Types are type aliases that are treated as different types externally. They provide type safety without runtime overhead, very useful for domain modeling.

object UserId:
  opaque type UserId = Long

  def apply(id: Long): UserId = id

  extension (id: UserId)
    def value: Long = id
    def isValid: Boolean = id > 0

import UserId.*

val id: UserId = UserId(42)
id.value       // 42
id.isValid     // true

// val x: Long = id  // Compile error! UserId != Long

Benefits

  • No runtime overhead (no boxing)
  • Provides type safety
  • Useful for domain modeling
object Money:
  opaque type USD = BigDecimal
  opaque type EUR = BigDecimal

  def usd(amount: BigDecimal): USD = amount
  def eur(amount: BigDecimal): EUR = amount

  extension (x: USD)
    def +(y: USD): USD = x + y
    def value: BigDecimal = x

import Money.*

val dollars = usd(100)
val more = usd(50)
val total = dollars + more  // OK: USD + USD

// val mixed = dollars + eur(50)  // Compile error! USD + EUR
Key Points
  • Opaque Type is a type alias treated as different type externally
  • Provides type safety without runtime overhead (no boxing)
  • Useful for preventing type confusion in domain modeling

Match Types#

Match Types perform pattern matching at the type level. They can compute different result types based on input types.

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

val a: Elem[String] = 'a'        // Char
val b: Elem[Array[Int]] = 1      // Int
val c: Elem[List[String]] = "hi" // String

Recursive Match Types

type Flatten[X] = X match
  case List[List[t]] => Flatten[List[t]]
  case List[t]       => List[t]

// List[List[List[Int]]] -> List[Int]
val x: Flatten[List[List[List[Int]]]] = List(1, 2, 3)
Key Points
  • Match Type performs pattern matching at type level
  • Computes different result types based on input types
  • Complex type transformations possible with recursive Match Types

Type Lambdas#

Type Lambdas allow you to pass type constructors as type parameters. Useful when working with higher-kinded types.

// Type lambda: [X] =>> F[X, Y]
type IntMap = [V] =>> Map[Int, V]

val map: IntMap[String] = Map(1 -> "one", 2 -> "two")
// Actually Map[Int, String]

// Either with fixed error type
type Result = [A] =>> Either[String, A]

val ok: Result[Int] = Right(42)
val err: Result[Int] = Left("Error")
Key Points
  • Type Lambda expresses type constructors in form [X] =>> F[X]
  • Useful for partial application of type parameters when working with higher-kinded types
  • Provides a concise alternative to Scala 2’s complex syntax

Dependent Function Types#

Dependent Function Types are function types where the return type depends on the parameter value.

trait Key:
  type Value

val intKey = new Key { type Value = Int }
val strKey = new Key { type Value = String }

// Return type depends on key's Value type
def get(key: Key): key.Value = ???

// Dependent function type
val getter: (key: Key) => key.Value = (key: Key) => ???

Polymorphic Function Types#

Polymorphic Function Types are function types with type parameters. Function values themselves can be generic.

// Polymorphic function type
val identity: [A] => A => A = [A] => (a: A) => a

identity[Int](42)       // 42
identity[String]("hi")  // "hi"

// First element of list
val head: [A] => List[A] => A = [A] => (list: List[A]) => list.head

head(List(1, 2, 3))  // 1

Comparison with Scala 2#

The table below summarizes differences in advanced type features between Scala 2 and Scala 3.

FeatureScala 2Scala 3
Union TypesUse EitherA | B
IntersectionA with BA & B
Opaque TypesValue Classopaque type
Match TypesNot possibleSupported
Type LambdasComplex syntax[X] =>> F[X]

Exercises#

1. Implement Opaque Type ⭐⭐

Implement an Email opaque type. Include validation.

Show Answer
object Email:
  opaque type Email = String

  def apply(value: String): Option[Email] =
    if value.contains("@") && value.contains(".") then Some(value)
    else None

  def unsafe(value: String): Email = value

  extension (email: Email)
    def value: String = email
    def domain: String = email.split("@").last

import Email.*

val valid = Email("user@example.com")   // Some(Email)
val invalid = Email("invalid")          // None

valid.foreach(e => println(e.domain))   // "example.com"

Next Steps#