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.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.