Skip to Content
Course content

27: Implicits and Given Instances (Scala 3)

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I see this all the time when developers move from Java or Python to Scala: they treat given and using as just a "clever" way to avoid typing arguments in a function call. They think of it as a form of dependency injection or a shortcut to hide boilerplate. But if you view it as "magic hidden arguments," you'll eventually hit a wall where the compiler starts complaining and you have no idea why.

Givens aren't "hidden arguments," they are proofs of capability

The mistake is thinking that given is about convenience. In reality, it's about evidence. When you define a given instance, you aren't just providing a value; you are telling the compiler: "I have a way to handle this specific type for this specific purpose."

Let's look at a real-world scenario. Imagine we're building a system that converts objects to JSON. We don't want to write the serialization logic inside every single function. Instead, we want to say: "This function can run, provided there is a JsonWriter available for the type I'm passing in."

case class User(name: String, age: Int)

trait JsonWriter[T]:
  def write(value: T): String

// If we did this "manually" without givens:
def serializeUserManual(user: User, writer: JsonWriter[User]): String =
  writer.write(user)

val userWriter = new JsonWriter[User]:
  def write(value: User): String = s"""{"name": "${value.name}", "age": ${value.age}}"""

// This is tedious. Every time I call serialize, I have to lug the writer around.
serializeUserManual(User("Alice", 30), userWriter)

In the manual version, the JsonWriter is just another parameter. But in a complex system, you might have 10 levels of function calls, and every single one of them needs that writer. Passing it manually is a nightmare. This is where given and using come in. They shift the responsibility of providing the writer from the caller to the scope.

Separating the 'Provision' from the 'Requirement'

In Scala 2, the keyword implicit did everything. It was used to define the value, the parameter, and the conversion. It was confusing as hell. Scala 3 splits this into two distinct concepts: given (the provision) and using (the requirement).

Here is how we rewrite that JSON logic using the Scala 3 way. I personally find this much cleaner because you can glance at a function signature and immediately know which arguments are mandatory and which are contextual.

trait JsonWriter[T]:
  def write(value: T): String

// 'using' tells the compiler: "Look in the scope for a 'given' of this type"
def serialize[T](value: T)(using writer: JsonWriter[T]): String =
  writer.write(value)

// 'given' tells the compiler: "Here is the evidence that we know how to write a User"
given userWriter: JsonWriter[User] with
  def write(value: User): String = s"""{"name": "${value.name}", "age": ${value.age}}"""

// Now, the call is clean. The compiler finds 'userWriter' automatically.
val json = serialize(User("Bob", 25)) 

Notice that serialize is now generic. It doesn't care if you're passing a User, a Product, or a List, as long as a given JsonWriter exists for that type. If you try to call serialize(123) without defining a given JsonWriter[Int], the code won't even compile. The compiler isn't just looking for a variable; it's verifying that the capability to serialize an Int exists.

Controlling the Scope

One thing to keep in mind: the compiler looks for given instances in the local scope, then in the imported scopes. I often suggest grouping your givens into a trait or an object and importing them specifically when needed. This prevents your global namespace from becoming a dumping ground for instances.

object UserFormatting:
  given userWriter: JsonWriter[User] with
    def write(value: User): String = s"""{"name": "${value.name}"}"""

// Inside some other service:
import UserFormatting.given 
serialize(User("Charlie", 40))

By importing UserFormatting.given, you are explicitly bringing that "evidence" into your current context. It's a powerful pattern because it allows you to switch behaviors (like switching from a JSON formatter to an XML formatter) just by changing an import, without touching the business logic in your serialize function.




📋 Practical Task

Implementing a Locale-Aware Currency Formatter

You are building a financial dashboard. Depending on the user's locale, currencies need to be formatted differently (e.g., $100.00 for US and 100,00 € for EU). You need to implement this using given and using so that the formatting logic is decoupled from the display logic.

Requirements:

  • Create a trait CurrencyFormatter with a method format(amount: Double): String.
  • Create two given instances of CurrencyFormatter: one for USFormatter and one for EUFormatter.
  • Write a function printBalance(amount: Double)(using formatter: CurrencyFormatter) that prints the formatted amount to the console.
  • Demonstrate how to call printBalance using both formatters by controlling the scope (using local given definitions or imports).

Starter Code:

trait CurrencyFormatter:
  def format(amount: Double): String

def printBalance(amount: Double)(using formatter: CurrencyFormatter): Unit = 
  println(s"Your balance is ${formatter.format(amount)}")

// Your implementation here
Rating
0 0

There are no comments for now.

to be the first to leave a comment.