Scala - Given/Using (Scala 3 Implicits)
• Scala 3's `given` and `using` keywords replace implicit parameters and implicit values with clearer, more intentional syntax that makes dependencies explicit at both definition and call sites
Key Insights
• Scala 3’s given and using keywords replace implicit parameters and implicit values with clearer, more intentional syntax that makes dependencies explicit at both definition and call sites
• The given keyword defines contextual instances that the compiler can automatically provide, while using clauses explicitly mark parameters that accept these contextual values
• Given instances support precise import control through import given syntax, preventing accidental implicit pollution and making code dependencies more maintainable
Understanding the Shift from Implicits
Scala 3 fundamentally redesigned how contextual abstractions work. The old implicit keyword handled multiple concerns—implicit conversions, implicit parameters, and typeclass instances—creating confusion about intent. The new system separates these concerns with dedicated syntax.
// Scala 2 style
implicit val ordering: Ordering[Person] = Ordering.by(_.age)
def sortPeople(people: List[Person])(implicit ord: Ordering[Person]) =
people.sorted
// Scala 3 style
given Ordering[Person] = Ordering.by(_.age)
def sortPeople(people: List[Person])(using ord: Ordering[Person]) =
people.sorted
The given keyword signals that you’re defining a value intended for contextual abstraction. The using keyword marks parameters that should be resolved from context. This explicit separation makes code intent immediately clear.
Defining Given Instances
Given instances are values the compiler can automatically provide when needed. They’re the foundation of typeclasses, dependency injection patterns, and context propagation.
case class User(id: String, name: String)
trait JsonEncoder[A]:
def encode(value: A): String
given JsonEncoder[User] with
def encode(user: User): String =
s"""{"id":"${user.id}","name":"${user.name}"}"""
given JsonEncoder[Int] with
def encode(value: Int): String = value.toString
// Anonymous given - no name needed when type is sufficient
given JsonEncoder[String] = (s: String) => s"\"$s\""
When a given implements a trait, use with for multi-line definitions or = for single expressions. The compiler identifies givens by their type, not their name, though you can optionally name them for clarity.
Using Clauses in Action
Using clauses replace implicit parameters, making it explicit which parameters come from context rather than direct invocation.
def toJson[A](value: A)(using encoder: JsonEncoder[A]): String =
encoder.encode(value)
val user = User("123", "Alice")
println(toJson(user)) // Compiler finds given JsonEncoder[User]
println(toJson(42)) // Compiler finds given JsonEncoder[Int]
You can omit the parameter name when you don’t need to reference it directly:
def toJson[A](value: A)(using JsonEncoder[A]): String =
summon[JsonEncoder[A]].encode(value)
The summon function retrieves a given instance from context. This pattern is useful when you need the instance but don’t want to name the parameter.
Context Bounds Simplified
Context bounds provide syntactic sugar for common using clause patterns. They’re particularly clean for typeclass-based APIs.
// Using clause style
def processData[A](data: List[A])(using encoder: JsonEncoder[A]): String =
data.map(encoder.encode).mkString("[", ",", "]")
// Context bound style
def processData[A: JsonEncoder](data: List[A]): String =
data.map(summon[JsonEncoder[A]].encode).mkString("[", ",", "]")
Context bounds are equivalent to using clauses but more concise when you only need the type constraint. Use them when the given instance is accessed through summon rather than direct parameter reference.
Conditional Given Instances
You can define given instances that only exist when certain conditions are met. This enables powerful compositional patterns.
given [A](using encoder: JsonEncoder[A]): JsonEncoder[List[A]] with
def encode(list: List[A]): String =
list.map(encoder.encode).mkString("[", ",", "]")
given [A](using encoder: JsonEncoder[A]): JsonEncoder[Option[A]] with
def encode(opt: Option[A]): String = opt match
case Some(value) => encoder.encode(value)
case None => "null"
val users = List(User("1", "Alice"), User("2", "Bob"))
println(toJson(users)) // Works because JsonEncoder[User] exists
val maybeUser: Option[User] = Some(User("3", "Carol"))
println(toJson(maybeUser)) // Works through composition
The compiler automatically constructs JsonEncoder[List[User]] by combining the conditional given for List[A] with the given for User. This creates a derivation chain without explicit code.
Import Control with Given
Scala 3 provides precise control over which given instances are imported, preventing namespace pollution.
object Encoders:
given JsonEncoder[User] with
def encode(user: User): String = s"""{"id":"${user.id}"}"""
given JsonEncoder[Int] = _.toString
val normalValue = 42 // Not a given
// Import only given instances
import Encoders.given
// Import specific given by type
import Encoders.{given JsonEncoder[User]}
// Import all members including givens
import Encoders.*
The import given syntax imports only contextual instances, excluding regular values and methods. This prevents accidental shadowing and makes dependencies explicit.
Given Instances with Parameters
Given instances can have regular parameters, enabling configuration while maintaining contextual abstraction.
case class Config(indent: Boolean, pretty: Boolean)
class JsonWriter(config: Config):
def write[A](value: A)(using encoder: JsonEncoder[A]): String =
val json = encoder.encode(value)
if config.pretty then formatPretty(json) else json
private def formatPretty(json: String): String =
// Formatting logic
json
given defaultConfig: Config = Config(indent = true, pretty = true)
given (using config: Config): JsonWriter = JsonWriter(config)
// Usage automatically resolves both Config and JsonWriter
def saveToFile[A: JsonEncoder](value: A, path: String)(using writer: JsonWriter): Unit =
val json = writer.write(value)
// Save to file
()
This pattern supports dependency injection where given instances can depend on other given instances, creating a resolution chain.
Given Aliases for Type Refinement
Use given aliases to provide contextual instances for refined types without creating new instances.
trait Ordering[A]:
def compare(x: A, y: A): Int
given intOrdering: Ordering[Int] with
def compare(x: Int, y: Int): Int = x - y
// Alias for reversed ordering
given reversedIntOrdering: Ordering[Int] =
(x: Int, y: Int) => intOrdering.compare(y, x)
type DescendingInt = Int
given Ordering[DescendingInt] = reversedIntOrdering
Aliases let you reuse existing instances under different names or types, avoiding duplication while maintaining type safety.
Migration from Scala 2
The Scala 3 compiler accepts Scala 2’s implicit syntax but encourages migration. Here’s a systematic approach:
// Scala 2
implicit val userOrdering: Ordering[User] = Ordering.by(_.name)
implicit def listOrdering[A](implicit ord: Ordering[A]): Ordering[List[A]] = ???
// Scala 3 migration
given userOrdering: Ordering[User] = Ordering.by(_.name)
given [A](using ord: Ordering[A]): Ordering[List[A]] = ???
Replace implicit val with given, implicit def with given, and implicit parameters with using clauses. The compiler provides warnings to guide migration, and both syntaxes can coexist during transition.
Performance Considerations
Given instances are resolved at compile time, not runtime. The compiler generates code that passes given instances as regular parameters, so there’s no performance penalty compared to explicit parameter passing.
// This code
def process[A: JsonEncoder](value: A): String = toJson(value)
// Compiles to equivalent of
def process[A](value: A)(encoder: JsonEncoder[A]): String =
toJson(value)(encoder)
The abstraction is zero-cost. Given instances are singleton objects when possible, reused across call sites without allocation overhead.