Scala - Scala 3 New Features Overview
• Scala 3 introduces significant syntax improvements including top-level definitions, new control structure syntax, and optional braces, making code more concise and Python-like
Key Insights
• Scala 3 introduces significant syntax improvements including top-level definitions, new control structure syntax, and optional braces, making code more concise and Python-like • The new type system features union and intersection types, opaque type aliases, and match types that provide stronger compile-time guarantees without runtime overhead • Metaprogramming gets a complete overhaul with inline methods and the new macro system replacing Scala 2’s reflection-based approach with a safer, typed alternative
Top-Level Definitions and Package Objects
Scala 3 eliminates the need to wrap everything in objects or classes. You can now define methods, values, and type aliases directly at the package level.
// Scala 2 - required package object
package object utils {
def sanitize(input: String): String = input.trim.toLowerCase
val MAX_RETRIES = 3
}
// Scala 3 - direct top-level definitions
package utils
def sanitize(input: String): String = input.trim.toLowerCase
val MAX_RETRIES = 3
type UserId = Long
type Result[T] = Either[String, T]
This change reduces boilerplate and makes Scala code feel more like modern scripting languages while maintaining type safety. Top-level definitions are compiled into synthetic objects behind the scenes, so there’s no performance penalty.
Union and Intersection Types
Scala 3’s type system now supports union types (A | B) and intersection types (A & B) natively, replacing the complex type-level encodings required in Scala 2.
// Union types - value can be either type
def handle(error: String | Exception): Unit = error match {
case s: String => println(s"Error message: $s")
case e: Exception => println(s"Exception: ${e.getMessage}")
}
// Intersection types - value must satisfy both types
trait Loggable {
def log(): Unit
}
trait Serializable {
def serialize(): String
}
def process(obj: Loggable & Serializable): String = {
obj.log()
obj.serialize()
}
// Practical example: API response handling
type ApiResponse = Success | ClientError | ServerError
case class Success(data: String)
case class ClientError(code: Int, message: String)
case class ServerError(message: String)
def handleResponse(response: ApiResponse): Unit = response match {
case Success(data) => println(s"Data: $data")
case ClientError(code, msg) => println(s"Client error $code: $msg")
case ServerError(msg) => println(s"Server error: $msg")
}
Union types are particularly valuable for error handling and API design, providing type-safe alternatives to throwing exceptions or using complex Either chains.
Enums and Algebraic Data Types
Scala 3 introduces proper enum support, simplifying the definition of ADTs and sum types.
// Simple enum
enum Color {
case Red, Green, Blue
}
// Enum with parameters
enum HttpMethod(val isSafe: Boolean) {
case GET extends HttpMethod(true)
case POST extends HttpMethod(false)
case PUT extends HttpMethod(false)
case DELETE extends HttpMethod(false)
}
// ADT with enum - cleaner than sealed traits
enum Result[+T] {
case Success(value: T)
case Failure(error: String)
case Pending
def map[U](f: T => U): Result[U] = this match {
case Success(v) => Success(f(v))
case Failure(e) => Failure(e)
case Pending => Pending
}
}
// Usage
val result: Result[Int] = Result.Success(42)
val doubled = result.map(_ * 2)
Enums compile to sealed classes, providing exhaustiveness checking in pattern matching while offering cleaner syntax than Scala 2’s sealed trait approach.
Extension Methods
Extension methods in Scala 3 replace implicit classes with a more straightforward syntax for adding methods to existing types.
// Scala 2 - implicit class
implicit class StringOps(s: String) {
def toSnakeCase: String =
s.replaceAll("([A-Z])", "_$1").toLowerCase.tail
}
// Scala 3 - extension methods
extension (s: String) {
def toSnakeCase: String =
s.replaceAll("([A-Z])", "_$1").toLowerCase.tail
def truncate(maxLength: Int): String =
if (s.length <= maxLength) s
else s.take(maxLength - 3) + "..."
}
// Multiple extensions for same type
extension (s: String) {
def isEmail: Boolean = s.matches("""[\w.]+@[\w.]+""")
def isUrl: Boolean = s.startsWith("http://") || s.startsWith("https://")
}
// Generic extensions
extension [T](list: List[T]) {
def second: Option[T] = list.drop(1).headOption
def partitionBy[K](f: T => K): Map[K, List[T]] = list.groupBy(f)
}
// Usage
"HelloWorld".toSnakeCase // "hello_world"
List(1, 2, 3).second // Some(2)
Given Instances and Using Clauses
Scala 3 replaces implicit parameters with given instances and using clauses, making the intent explicit and reducing confusion.
// Define type class
trait JsonEncoder[T] {
def encode(value: T): String
}
// Scala 2 style
implicit val intEncoder: JsonEncoder[Int] = (value: Int) => value.toString
// Scala 3 - given instances
given JsonEncoder[Int] with {
def encode(value: Int): String = value.toString
}
given JsonEncoder[String] with {
def encode(value: String): String = s""""$value""""
}
given [T](using enc: JsonEncoder[T]): JsonEncoder[List[T]] with {
def encode(values: List[T]): String =
values.map(enc.encode).mkString("[", ",", "]")
}
// Using clauses instead of implicit parameters
def toJson[T](value: T)(using encoder: JsonEncoder[T]): String =
encoder.encode(value)
// Anonymous given - when name doesn't matter
given JsonEncoder[Boolean] = (value: Boolean) => value.toString
// Usage - still automatic
toJson(42) // "42"
toJson(List(1, 2, 3)) // "[1,2,3]"
Opaque Type Aliases
Opaque types provide zero-cost abstraction, creating type-safe wrappers without runtime overhead.
object domain {
opaque type UserId = Long
opaque type Email = String
object UserId {
def apply(value: Long): UserId = value
extension (id: UserId) {
def toLong: Long = id
}
}
object Email {
def apply(value: String): Option[Email] =
if (value.contains("@")) Some(value) else None
extension (email: Email) {
def value: String = email
def domain: String = email.split("@")(1)
}
}
}
import domain._
val userId = UserId(12345)
val email = Email("user@example.com").get
// Type safety - won't compile
// val wrong: UserId = email
// No boxing/unboxing - direct Long operations
def findUser(id: UserId): Unit = {
val rawId: Long = id.toLong
println(s"Finding user $rawId")
}
Inline and Metaprogramming
Scala 3’s inline feature enables compile-time evaluation and a new macro system.
// Inline methods - evaluated at compile time
inline def debug(inline msg: String): Unit =
inline if (sys.env.get("DEBUG").isDefined) {
println(s"[DEBUG] $msg")
}
// Compile-time operations
inline def isPowerOfTwo(n: Int): Boolean =
n > 0 && (n & (n - 1)) == 0
transparent inline def max(inline a: Int, inline b: Int): Int =
if (a > b) a else b
// Simple macro using quotes
import scala.quoted._
inline def showExpr[T](inline expr: T): T = {
${ showExprImpl('expr) }
}
def showExprImpl[T: Type](expr: Expr[T])(using Quotes): Expr[T] = {
import quotes.reflect._
println(s"Expression: ${expr.show}")
expr
}
// Usage
val result = showExpr(2 + 2) // Prints: Expression: 2.+(2)
Contextual Abstractions Summary
The combination of given/using, extension methods, and type classes creates a powerful system for contextual abstractions.
trait Ordering[T] {
def compare(x: T, y: T): Int
}
given Ordering[Int] with {
def compare(x: Int, y: Int): Int = x - y
}
extension [T](list: List[T]) {
def sorted(using ord: Ordering[T]): List[T] =
list.sortWith((a, b) => ord.compare(a, b) < 0)
}
// Automatic resolution
List(3, 1, 2).sorted // List(1, 2, 3)
Scala 3’s changes represent a significant evolution in language design. The new features reduce boilerplate, improve type safety, and make the language more approachable while maintaining backward compatibility with most Scala 2 code through Scala 3’s migration tools.