Replacing Implicits with Given and Using in Scala 3
Learn how Scala 3's given and using clauses replace the overloaded implicit keyword to provide clearer dependency injection and type-class resolution.
19 Aug 2025, 08:27 UTC

In Scala 2, the implicit keyword was overloaded. It handled implicit parameters, implicit conversions, and implicit definitions. This ambiguity often turned large codebases into a "black box," where tracing the origin of a dependency required a forensic search through imports, leading to the dreaded "ambiguous implicit values" compiler error.
Scala 3 solves this by splitting the concept into two distinct constructs: given and using. This change separates the provision of a dependency from its requirement, making the compiler's search logic more predictable and the developer's intent explicit.
Decoupling Provision from Requirement
The primary engineering benefit of this shift is the elimination of the single implicit keyword. In Scala 3, you no longer use one word for three different behaviors. Instead, you use given to define a value that the compiler can search for, and using to declare a parameter that the compiler should attempt to fill automatically.
This distinction prevents accidental implicit conversions—where the compiler changes a type to make a method call work—from being confused with dependency injection or type-class resolution. By narrowing the scope of these keywords, the code becomes easier to reason about during static analysis.
Practical Example: Contextual Logging
Consider a scenario where multiple services require a logger. Rather than passing a logger instance manually through every function call (boilerplate) or relying on a global singleton (hard to test), you can use contextual abstractions.
// Define the type-class trait
trait Logger[S] {
def info(message: String): Unit
}
// Concrete implementation for a specific service
case class UserService(id: Int)
class ConsoleLogger extends Logger[UserService] {
def info(message: String): Unit = println(s"[UserService-INFO] $message")
}
// Provide the 'given' instance in the current scope
given Logger[UserService] = new ConsoleLogger()
// Use 'using' to require the dependency without manual passing
def processUser(user: UserService)(using log: Logger[UserService]): Unit = {
log.info(s"Processing user with ID: ${user.id}")
}
@main def runExample(): Unit = {
val user = UserService(42)
// The compiler automatically finds the given Logger[UserService]
processUser(user)
}
In this example, processUser explicitly states it requires a Logger[UserService] in its signature via the using clause. However, the caller of processUser does not need to provide it manually because the given instance is available in the scope. If the given definition were removed, the compiler would throw a clear error: could not find given Logger[UserService].
Verifying Resolution and Diagnostics
When working in complex projects with multiple given instances, it can be difficult to know which one the compiler selected. To verify the resolution process, you can use the -Xshow-implicit-arguments compiler flag. This forces the compiler to print the actual arguments it is inserting into the using slots during compilation.
If using sbt, add the following to your build configuration:
scalacOptions += "-Xshow-implicit-arguments"
Running a compile task with this flag will output the specific given instances being injected, allowing you to diagnose incorrect resolution or unexpected overrides.
Trade-offs and Engineering Constraints
While given/using improves clarity, it introduces specific engineering considerations:
- Global State Risks: Defining
giveninstances in top-level packages can inadvertently create hidden global state, making unit testing difficult if those givens are not easily overridable. - Migration Complexity: While Scala 3 supports Scala 2
implicitsyntax for compatibility, mixing both in a single module can confuse team members and lead to inconsistent coding styles. - Learning Curve: Developers transitioning from Java or Python may find the concept of "contextual parameters" unintuitive, potentially leading to the overuse of
givenwhere a simple constructor injection would be clearer.
To check the result of your implementation, compile the code using Scala 3.3.x. If the using parameter is resolved, the program will execute the logic within the given instance. If resolution fails, the compiler will pinpoint the missing type in the using clause.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.