Technology  /  Scala

๐Ÿ”ด Scala 21 guides ยท updated 2026

The JVM's hybrid OOP/functional language โ€” immutability, pattern matching, and the type system that powers Spark and Akka, from first principles to production.

Scala 3 New Features: Enums, Union Types, Extension Methods and More

Scala 3 (released 2021, following years of development under the codename โ€œDottyโ€) wasnโ€™t a minor version bump โ€” itโ€™s a substantial redesign touching the type system, syntax, and several long-standing pain points covered throughout this series. This closing guide pulls together the Scala 3-specific features not yet covered elsewhere: proper enum support, union and intersection types, optional significant-indentation syntax, and a few smaller but genuinely useful additions.


enum โ€” A Real Alternative to the sealed trait Pattern

enum PaymentMethod:
case CreditCard(number: String)
case PayPal(email: String)
case Cash
val payment: PaymentMethod = PaymentMethod.CreditCard("1234-5678")
def fee(method: PaymentMethod): Double = method match
case PaymentMethod.CreditCard(_) => 2.50
case PaymentMethod.PayPal(_) => 1.00
case PaymentMethod.Cash => 0.0

This is functionally the same โ€œclosed set of variantsโ€ pattern the traits and pattern matching guides built from sealed trait + case class/case object, now expressed with a dedicated enum keyword โ€” Scala 3โ€™s enum compiles down to essentially the same sealed-hierarchy shape underneath, with less boilerplate at the declaration site. Exhaustiveness checking (covered fully in the pattern matching guide) works identically here โ€” the compiler still verifies every PaymentMethod variant is handled in a match.

// Simple, parameterless enums โ€” the case Scala 3's enum genuinely shines for
enum Direction:
case North, South, East, West
val d: Direction = Direction.North
// Enums can have members too, just like a sealed trait hierarchy could
enum Planet(val distanceFromSunMillionKm: Double):
case Mercury extends Planet(57.9)
case Venus extends Planet(108.2)
case Earth extends Planet(149.6)
Planet.Earth.distanceFromSunMillionKm // 149.6

For the specific case of a simple, no-data enumeration (Direction above), enum is a genuine improvement over the older sealed trait + case object pattern โ€” one line instead of five. For variants carrying different data per case (like PaymentMethod), the improvement is more modest (mainly namespacing and slightly less boilerplate), but real โ€” and either style remains valid; enum didnโ€™t deprecate the sealed trait pattern, it added a more concise option for the cases where it fits cleanly.

// Java-interop: Scala 3 enums can implement java.lang.Enum-compatible behavior
enum Color derives CanEqual:
case Red, Green, Blue

Union Types โ€” A New Way to Say โ€œA or Bโ€

def process(input: String | Int): String = input match
case s: String => s"String: $s"
case i: Int => s"Int: $i"
process("hello") // "String: hello"
process(42) // "Int: 42"

String | Int is a union type โ€” a value thatโ€™s genuinely one of several distinct types, with no shared parent type needed at all. Before Scala 3, expressing โ€œthis parameter accepts a String or an Intโ€ required either overloading the function, wrapping both in Either, or falling back to a common supertype like Any (losing type information entirely). Union types express this directly, and โ€” notably โ€” this is precisely the same feature covered in the TypeScript seriesโ€™ union types guide, arriving in Scala roughly the same era, reflecting a broader trend of statically-typed languages adopting union types as a first-class way to model โ€œone of several known shapes.โ€

// Union types narrow via pattern matching, exactly like TypeScript's narrowing
def describe(value: Int | String | Boolean): String = value match
case n: Int => s"a number: $n"
case s: String => s"a string: $s"
case b: Boolean => s"a boolean: $b"

is a String

is an Int

String | Int

match on the actual runtime type

Handled as String

Handled as Int


Intersection Types โ€” โ€œA and B, Combinedโ€

trait Named:
def name: String
trait Aged:
def age: Int
def describe(person: Named & Aged): String = s"${person.name}, ${person.age} years old"
class Person(val name: String, val age: Int) extends Named with Aged
describe(new Person("Alice", 30))

Named & Aged (using & in Scala 3, replacing the older with for this specific role) requires a value to satisfy both traits simultaneously โ€” this is the direct type-level equivalent of the intersection types covered in the TypeScript series, letting a functionโ€™s parameter type express โ€œmust have both of these capabilitiesโ€ without needing to define a new combined trait explicitly extending both.


Significant Indentation โ€” Optional, Brace-Free Syntax

// Traditional brace syntax โ€” still fully supported
def greet(name: String): String = {
val greeting = "Hello"
s"$greeting, $name!"
}
// Significant-indentation syntax โ€” braces replaced by consistent indentation
def greetIndented(name: String): String =
val greeting = "Hello"
s"$greeting, $name!"
// A larger example showing the indentation style throughout
class BankAccount(initialBalance: Double):
private var balance = initialBalance
def deposit(amount: Double): Unit =
balance += amount
def withdraw(amount: Double): Unit =
if amount > balance then
throw new IllegalStateException("Insufficient funds")
else
balance -= amount
def getBalance: Double = balance

Scala 3 makes braces optional for class/method/if/match bodies, using consistent indentation to delimit blocks instead โ€” directly inspired by Pythonโ€™s approach, and a genuine attempt to reduce visual noise in code that (per the hybrid-design theme of this whole series) already leans toward dense, expression-heavy style. This is entirely optional: brace-based syntax remains fully supported and equally idiomatic, and large existing codebases (and this seriesโ€™ own earlier code examples) continue to use braces without needing migration. Mixing styles within a single file is discouraged for consistency, but the compiler accepts both throughout a project.


Extension Methods โ€” Already Covered, Worth Re-Anchoring Here

extension (s: String)
def isPalindrome: Boolean = s == s.reverse
"racecar".isPalindrome // true

Covered in full in the given and using guide as the replacement for Scala 2โ€™s implicit classes โ€” mentioned again here because itโ€™s genuinely one of Scala 3โ€™s most immediately useful new features for everyday code, not just an implicits-specific cleanup.


Opaque Types โ€” Zero-Cost Type Safety Wrappers

opaque type UserId = String
object UserId:
def apply(value: String): UserId = value
extension (id: UserId) def value: String = id
def findUser(id: UserId): String = s"Looking up user ${id.value}"
findUser(UserId("u123")) // fine
findUser("u123") // Error: String is not a UserId โ€” even though UserId IS a String underneath

An opaque type is a genuinely distinct type at compile time (preventing an ordinary String from being passed where a UserId is required, catching a whole class of โ€œpassed the wrong string-typed argumentโ€ bugs) while compiling down to exactly the underlying type at runtime โ€” no wrapper object, no boxing, zero runtime overhead. This solves a real, common tension: a hand-written case class UserId(value: String) gives you the same compile-time distinction, but at the cost of an actual wrapper object allocation for every UserId; opaque type gives you the type safety without that cost, at the price of needing explicit extension methods to access the underlying value from outside its defining scope.


Top-Level Definitions โ€” No Wrapper Object Required

// A whole file's worth of Scala 3 code, no wrapping object needed at all
def add(a: Int, b: Int): Int = a + b
val pi = 3.14159
@main def run(): Unit =
println(add(2, 3))

Scala 2 required every top-level def/val to live inside an object or class โ€” Scala 3 permits genuinely top-level definitions directly in a file, reducing ceremony for scripts and small modules. This pairs naturally with the @main entry point mentioned in the first guide of this series, letting a complete small program be written with almost no structural boilerplate at all.


Multiversal Equality โ€” Catching Nonsensical Comparisons at Compile Time

case class Apple(variety: String)
case class Orange(variety: String)
val apple = Apple("Fuji")
val orange = Orange("Navel")
apple == orange // Scala 2: compiles fine, always false at runtime โ€” comparing unrelated types
// Scala 3 with strict equality enabled
import scala.language.strictEquality
case class Apple(variety: String) derives CanEqual
case class Orange(variety: String) derives CanEqual
apple == orange // Error: cannot compare Apple and Orange โ€” no CanEqual instance defined between them

By default, Scala (both 2 and 3) allows == between any two types, even completely unrelated ones โ€” comparing an Apple to an Orange compiles and simply always evaluates to false, silently masking whatโ€™s very likely a genuine bug (a typoโ€™d variable, a mismatched comparison) rather than flagging it. Scala 3โ€™s opt-in strict equality (via derives CanEqual and the strictEquality language import) makes this a compile-time error instead โ€” a CanEqual[Apple, Orange] instance must exist for the comparison to even type-check, and by default, only same-type (and otherwise explicitly declared) comparisons are permitted. This is the exact same category of โ€œthe compiler catches a nonsensical comparison before runtimeโ€ benefit that runs throughout this series, applied specifically to ==.

Scala 3 Feature Summary

FeatureReplaces / improves onCovered in depth
given/usingClassic implicitGiven and Using guide
extension methodsImplicit classesGiven and Using guide
enumsealed trait + case class/case object for simple casesThis guide
Union types (|)Overloading, Either, or AnyThis guide
Intersection types (&)with-based trait combination (mostly unchanged, renamed)This guide
Significant indentationMandatory bracesThis guide
opaque typeWrapper case classes with runtime costThis guide
Top-level definitionsMandatory wrapping objectThis guide

Frequently Asked Questions

Should I migrate an existing Scala 2 codebase to Scala 3 immediately? Not necessarily urgently โ€” Scala 3 maintains strong (though not 100%) binary and source compatibility with Scala 2.13, and the migration tooling (scalafix rules, cross-compilation support) is mature. Prioritize migration based on whether you need specific Scala 3 features, whether your dependencies have Scala 3 support, and whether the team has bandwidth, rather than migrating purely for its own sake.

Is enum strictly better than sealed trait + case class/case object? For simple, data-free enumerations, yes, clearly โ€” less code for the same result. For variants carrying different data, the improvement is real but more modest, and both patterns remain valid, idiomatic choices.

Do union types replace the need for Either? No โ€” they solve different problems. Union types describe โ€œthis valueโ€™s type is genuinely one of these,โ€ useful for parameters/return values with a small number of legitimately different shapes. Either specifically models a success/failure computation with an explicit error channel, which a bare union type doesnโ€™t capture (thereโ€™s no โ€œwhich side represents failureโ€ convention built into a plain union).

Is significant indentation required in new Scala 3 code? No โ€” itโ€™s entirely optional, and brace-based syntax remains fully idiomatic. Team/project convention should decide which to use consistently, rather than either being objectively mandated.

Whatโ€™s the single most impactful Scala 3 change for someone coming from Scala 2? Likely given/using and extension methods together โ€” they directly address implicitsโ€™ biggest readability criticism, which was historically the single most commonly cited reason developers found Scala harder to learn than its actual core language justified.

Is strict equality (CanEqual) enabled by default in Scala 3? No โ€” itโ€™s opt-in via import scala.language.strictEquality and derives CanEqual on the types involved, precisely because enforcing it universally would break a large amount of existing code relying on the looser default. New projects wanting this extra safety net can adopt it deliberately; itโ€™s not forced on every Scala 3 codebase automatically.


Series Wrap-Up

This closes the Scala series โ€” from the JVM-hosted, hybrid OOP/functional foundation in the first guide, through classes, traits, and case classes, into the functional programming core (immutability, collections, Option/Either/Try, pattern matching, for-comprehensions), the advanced type system (generics, variance, implicits, type classes), and finally concurrency, testing, and Scala 3โ€™s modernization. If youโ€™re starting fresh, revisit How Scala Actually Works for the mental model everything else builds on. If you came from an existing Scala 2 codebase specifically to understand Scala 3, the Given and Using guide and this one cover the changes youโ€™ll encounter most directly.