Skip to content
← All insights
Scala type system14 min read

Implicits in Scala: parameters, scope and context bounds

How implicit parameters, implicit scope and context bounds work in Scala 2.13, how Scala 3 expresses the same ideas, and where invisible dependencies become a maintenance problem.

An implicit parameter is still a parameter

Scala 2.13 can fill an implicit parameter when the caller omits it and exactly one suitable value can be found. The dependency remains part of the method signature; the compiler is performing argument resolution rather than creating a global value.

This is useful for stable contextual capabilities such as an Ordering, ExecutionContext or typeclass instance. It becomes harder to reason about when large services, request-specific state or behaviour with surprising side effects arrive implicitly.

A caller can always pass the argument explicitly. Doing so is useful while debugging resolution and at boundaries where the chosen instance deserves to be visible.

Make the inferred argument visiblescala 2.13
final case class TraceId(value: String)

def audit(message: String)(implicit traceId: TraceId): String =
  s"[${traceId.value}] $message"

implicit val currentTrace: TraceId = TraceId("trace-17")

audit("account loaded")
audit("account loaded")(currentTrace)

Implicit scope determines which value wins

The compiler first considers eligible identifiers available without a prefix, including local definitions, inherited members and imports. It also considers the implicit scope associated with the requested type, especially companion objects of the involved types.

A canonical typeclass instance often belongs in the companion of the data type or typeclass so it can be found without a broad import. Alternative policies should be named and imported close to the place selecting them.

No candidate produces a missing implicit error; several equally eligible candidates produce an ambiguity. Adding a wildcard import until the error disappears can make instance ownership less predictable. Ask which exact type is required and where its instance should live.

Keep the default instance discoverablescala 2.13
trait Display[A] {
  def apply(value: A): String
}

final case class AccountId(value: String)

object AccountId {
  implicit val displayAccountId: Display[AccountId] =
    new Display[AccountId] {
      def apply(value: AccountId): String = value.value
    }
}

def display[A](value: A)(implicit D: Display[A]): String = D(value)

display(AccountId("a-17"))

A context bound is shorthand for evidence

A declaration such as [A: Ordering] means the method requires an implicit Ordering[A]. In Scala 2.13 it is essentially shorthand for an additional implicit parameter and does not change A itself.

Use implicitly to retrieve the evidence when the method body needs it, or name the implicit parameter when the instance is central to the implementation. Multiple context bounds state multiple independent capabilities.

A context bound says that an operation is generic over A but requires known behaviour for A. If there is only one concrete type and no useful generic caller, an ordinary method or dependency may be simpler.

Expand a context bound mentallyscala 2.13
def earliest[A: Ordering](values: List[A]): Option[A] =
  values.sorted.headOption

// Equivalent requirement, with the evidence named:
def earliestExplicit[A](
  values: List[A]
)(implicit ordering: Ordering[A]): Option[A] =
  values.sorted(ordering).headOption

def compare[A: Ordering](left: A, right: A): Int =
  implicitly[Ordering[A]].compare(left, right)

Implicit conversions deserve restraint

Implicit parameters and implicit conversions use related resolution machinery but solve different problems. A conversion lets a value be treated as another type or supplies missing syntax through an implicit class. That can make an API pleasant, but it also makes method availability and runtime work less obvious.

Prefer implicit classes for small extension syntax in Scala 2.13 and keep their bodies thin. Avoid broad conversions between domain values: automatically converting String to AccountId bypasses the point of validating and distinguishing the identifier.

Compiler flags such as -feature help reveal conversion use. In review, inspect imports when a method appears to come from nowhere and confirm that the added syntax does not conceal I/O or expensive work.

Add syntax without weakening constructionscala 2.13
object DisplaySyntax {
  implicit final class DisplayOps[A](private val value: A) extends AnyVal {
    def display(implicit D: Display[A]): String = D(value)
  }
}

import DisplaySyntax._
AccountId("a-17").display

// Do not add an implicit String => AccountId conversion;
// construct and validate the domain value explicitly.

Scala 3 separates the concepts

Scala 3 uses given for contextual values, using for contextual parameters and extension for extension methods. summon retrieves contextual evidence. The terminology makes intent clearer, but the same design questions about visibility, ownership and ambiguity remain.

Migration is not only a syntax rewrite. Import behaviour and implicit resolution differ in places, and a previously selected instance may become ambiguous or stop being found. Protect behaviour that depends on ordering, codecs or another policy with direct tests.

Use contextual abstraction for a real capability or context. Constructor parameters remain the clearer choice for most application services because their lifecycle and dependencies are visible where the object is assembled.

Express the same capability in Scala 3scala 3
trait Display[A]:
  def apply(value: A): String

given Display[AccountId] with
  def apply(value: AccountId): String = value.value

def display[A: Display](value: A): String =
  summon[Display[A]](value)

extension [A: Display](value: A)
  def display: String = summon[Display[A]](value)

Review the hidden argument as an API decision

An implicit is healthy when callers can predict what will be selected and the dependency represents stable context or generic evidence. It is a smell when changing an import changes important behaviour, when several instances compete routinely or when reading a call gives no clue that I/O or mutable state is involved.

Keep implicit scopes narrow, give alternative instances descriptive names and avoid catch-all fallback instances. Compiler diagnostics become much easier when the requested type is precise and instance chains remain shallow.

The test is not whether implicits are advanced Scala. It is whether inference removes repetition while preserving the ability to understand the program from its types and module boundaries.

  • Can the required contextual type be seen in the signature?
  • Is there one obvious owner for the default instance?
  • Would an explicit constructor parameter make an application dependency clearer?
  • Can a broad import silently change the selected behaviour?
  • Does extension syntax remain cheap and unsurprising?
  • Are important instance choices covered by direct tests?