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.

Liked this? There's more.

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