Skip to content

08 · Scala 3 Features

You've used given/using (Level 2, formalized in Level 3's type classes), enum for ADTs, and extension methods throughout this course already — they're Scala 3's biggest departures from Scala 2, and you've been writing idiomatic Scala 3 the whole time. This module rounds out the set with two features not yet covered: opaque types and union types — plus a proper look at enums with data.

Enums beyond simple cases

Level 1's pattern matching used simple enums. Scala 3 enums can carry data per case, making them a concise way to define an algebraic data type that in Scala 2 needed a sealed trait plus several case classes:

enum Direction:
  case North, South, East, West
  def opposite: Direction = this match
    case North => South
    case South => North
    case East => West
    case West => East

println(Direction.North.opposite)   // South

enum Shape:
  case Circle(radius: Double)
  case Rectangle(w: Double, h: Double)

def area(s: Shape): Double = s match
  case Shape.Circle(r)       => math.Pi * r * r
  case Shape.Rectangle(w, h) => w * h

println(area(Shape.Circle(2.0)))       // 12.566370614359172
println(area(Shape.Rectangle(3, 4)))   // 12.0

An enum can even mix methods (like opposite) with data-carrying cases (like Circle/Rectangle) in the same declaration — this is the same exhaustiveness-checked ADT shape you've relied on since Level 1's pattern matching, just with case class-per-variant boilerplate collapsed into one enum block.

Opaque types: zero-cost type safety

Passing raw Ints around for things like user ids, order ids, and quantities makes it easy to accidentally swap two arguments of the same underlying type — the compiler can't catch chargeUser(orderId, userId) called with the arguments reversed if both are just Int. An opaque type gives you a distinct type at compile time with zero runtime overhead — it compiles down to the underlying type, no wrapper object allocated:

opaque type UserId = Int
object UserId:
  def apply(raw: Int): UserId = raw
  extension (id: UserId) def raw: Int = id

val uid = UserId(42)
println(uid.raw)   // 42
// uid: Int  -- would NOT compile; UserId and Int are distinct types outside this file

Outside the file that declares opaque type UserId = Int, UserId and Int are completely distinct, unrelated types — you cannot pass a plain Int where a UserId is expected, or vice versa, without going through UserId.apply/.raw. Inside the declaring file (and only there), the compiler still knows UserId is Int underneath, which is what lets .raw and apply be trivial identity functions with no boxing.

The trap: a case class wrapper isn't free

Before opaque types, the usual way to get this same safety was case class UserId(raw: Int) — which works, but allocates a real object on the heap for every UserId, wrapping the Int it holds. In a hot path creating millions of ids, that allocation is measurable. opaque type gives you the identical compile-time safety with none of the runtime cost — reach for it specifically when you want a distinct type purely as a compile-time distinction, not extra behavior (for extra methods and data, a case class remains the right tool).

Union types: "this or that," without a common supertype

Scala's had Either[L, R] since Level 2 for "one of two things, with a label for which." A union type (A | B) expresses the same "one of these types" idea directly in the type signature, without wrapping in Left/Right:

def processOrder(status: Int | String): String = status match
  case n: Int    => s"code $n"
  case s: String => s"status $s"

println(processOrder(404))        // code 404
println(processOrder("timeout"))  // status timeout

Int | String accepts either type directly at the call site — no Left(404)/Right("timeout") wrapping needed — and the match on concrete types (case n: Int, case s: String) is exhaustively checked by the compiler against the declared union.

The trap: union types vs. Either — pick based on symmetry

Either[L, R] carries a clear asymmetry (by convention, Left = failure, Right = success) and comes with map/flatMap biased toward Right — useful exactly when one side represents an error to propagate. A union type like Int | String has no such bias — both sides are just "valid inputs," with no special short-circuiting behavior. Reach for Either when one side means "stop, something went wrong"; reach for a union type when several genuinely different-but-valid input shapes need to flow through the same function, like processOrder above accepting either a numeric or string status.

Extension methods, revisited

Module 06 of Level 3 used extension to add .show to arbitrary types via a type class. The same syntax works directly on a concrete type without any type class machinery at all — this is Scala 3's replacement for Scala 2's implicit-class pattern for "add a method to a type I don't own":

case class Point(x: Int, y: Int)

extension (p: Point)
  def +(other: Point): Point = Point(p.x + other.x, p.y + other.y)

println(Point(1, 2) + Point(3, 4))   // Point(4,6)

How It Actually Works

Scala 3 enum compiles down to exactly the sealed-trait-plus-case-classes pattern from Level 1 and Level 3enum Color { case Red, Green, Blue } is desugared by the compiler into a sealed abstract class Color plus one case object per simple case (or case class per parameterized case), with a synthesized companion holding a values array and a valueOf/fromOrdinal lookup. There's no new JVM-level enum mechanism involved (Scala enums are unrelated to java.lang.Enum unless you explicitly extend java.lang.Enum for Java interop) — enum is purely a more concise surface syntax over machinery that already existed.

Opaque types achieve genuinely zero runtime cost by being a compile-time-only fiction: opaque type UserId = Int tells the type checker to treat UserId as a distinct, incompatible type from Int everywhere outside the defining scope (catching accidental mixups at compile time), but after compilation, every UserId is just an Int in the bytecode — no wrapper object, no boxing, no allocation. This is the mechanical reason opaque types beat a case class UserId(value: Int) wrapper for this purpose: a case class wrapper is a real object at runtime, allocated on the heap (or occasionally stack-allocated/escape- analyzed away by the JIT, but not guaranteed), while an opaque type is erased to the underlying representation with certainty, by construction, at compile time.

Union types (Int | String) are also a compile-time-only construct resolved through erasure: the compiler tracks that a value could be either type and requires you to narrow it (typically via pattern matching or isInstanceOf) before using type-specific operations, but at the bytecode level a union type erases to their common runtime supertype (often Object/Any) plus compiler-inserted instanceof checks and casts wherever you narrow it — there's no runtime "union" object; the JVM only ever holds one concrete value at a time, and the union is the compiler's bookkeeping about which concrete types are possible at that point in the code, not a real tagged wrapper the way Either's Left/Right are actual heap objects.

Cheat sheet

Feature Use it for
enum with data-carrying cases An ADT (sealed hierarchy) without per-case case class boilerplate
opaque type A = B A distinct compile-time type over B, zero runtime cost
A \| B (union type) "One of several valid, unrelated input shapes," no error bias
Either[L, R] "One of two outcomes," with L conventionally meaning failure
extension (x: T) def method = ... Add a method to a type you don't own, no type class needed

Exercise

Define opaque type Meters = Double and opaque type Feet = Double with apply/.value extensions for each, plus a toFeet(m: Meters): Feet conversion function — confirm the compiler rejects passing a Feet value where a Meters is expected even though both are Double underneath. Then write def parseConfig(value: String | Int | Boolean): String handling all three cases in a match, and an enum ConfigError with cases Missing(key: String) and Invalid(key: String, reason: String) used as the error type in an Either[ConfigError, String]-returning lookup function.