One keyword in Scala 2 did four unrelated jobs: it passed arguments you did not write, it declared the values that got passed, it silently converted one type into another, and it bolted methods onto types you did not own. Two of those jobs are how Cats, ZIO, circe and the standard library's own Ordering are built. One of them is the reason Scala got a reputation for spooky action at a distance. Scala 3 pulled them apart into four separate constructs, which is the clearest evidence that they were always four features wearing one costume.
Why it matters
Implicits are what enable libraries like Cats and ZIO. They are also the source of most confusing Scala error messages. Understanding them is essential for reading advanced Scala code, and constraint is essential for writing readable code.
The practical stakes are concrete. A type class encoded on implicit parameters lets you add an Ordering to a class from a third-party jar, or a JSON codec to a case class, without inheritance and without touching the original definition -- and the compiler proves at build time that the capability exists, so "can this be sorted?" becomes a compile error rather than a runtime exception. That is a large win. The same machinery, pointed at implicit conversions, lets an import three files away turn a type error into a silent success. Same keyword, same search, opposite value.
This article is about implicits as a language feature and a design tool: what each of the four jobs actually compiles to, what each costs, and which ones are worth the complexity. The search the compiler runs to find a candidate -- local scope versus implicit scope, companion-object lookup, priority and specificity, recursive derivation, divergence, ambiguity errors -- is a separate topic with its own article: Scala implicit resolution architecture. Everything below assumes that search works and asks what you should build on top of it.
One keyword, four jobs
In Scala 2 the modifier implicit appears in four syntactic positions that have almost nothing to do with each other:
// 1. REQUIRES a value -- a trailing parameter list the caller may omit
def query(sql: String)(implicit ec: ExecutionContext): Future[Rows]
// 2. PROVIDES a value -- eligible to be found by the search
implicit val defaultEc: ExecutionContext = ...
implicit def listOrdering[A](implicit a: Ordering[A]): Ordering[List[A]] = ...
// 3. CONVERTS -- a unary implicit def is a view from A to B
implicit def stringToPath(s: String): Path = Paths.get(s)
// 4. EXTENDS -- adds methods to a type you do not own
implicit class IntOps(val i: Int) extends AnyVal { def isEven = i % 2 == 0 }
Jobs 1 and 2 are two halves of one feature: a demand and a supply, matched by type. Job 3 is a completely different feature -- subtyping-by-fiat -- that happens to reuse the same candidate search. Job 4 is job 3 with a specific idiom layered on top: implicit class is not a primitive at all, it desugars into a plain class plus a conversion into it.
The conflation was the problem, and it was a readability problem before it was a correctness one. Reading implicit x: Foo in a signature, you cannot tell from the keyword whether Foo is being demanded or supplied -- you have to notice whether there is a val in front of it. Worse, all four jobs travel through the same import. A wildcard import mylib._ that you added to get an extension method also activates every conversion and every instance in that object. You could not enable the feature you wanted and ban the one you feared, because at the level of the keyword they were the same feature. Scala 2.10 partly acknowledged this by putting conversion definitions behind the scala.language.implicitConversions feature import, but conversion use stayed free.
The three patterns you will actually meet
Collapsing supply and demand back together, working Scala 2 code uses implicits in three recognisable shapes, and it is worth being able to name which one you are looking at before you reason about it.
Implicit parameters thread a value through a call graph without every intermediate function mentioning it. ExecutionContext is the canonical case: forty functions need it, one call site at the top supplies it. The runtime cost is zero -- it compiles to an ordinary extra argument.
Implicit conversions make a value of type A usable where B is expected. This is the one that hides things, and the one to be suspicious of.
Implicit classes add methods to an existing type. Syntactically the most pleasant, and the one with a real allocation cost if you write it carelessly.
Type classes are not a fourth pattern -- they are an encoding built on the first one, which is why they inherit its zero-cost dispatch and none of the conversion's opacity.
Type classes: retroactive extension without subtyping
The type class encoding is three parts: a trait parameterised by a type, instances of that trait for concrete types, and functions that demand an instance via an implicit parameter.
trait Show[A] { def show(a: A): String }
object Show {
implicit val showInt: Show[Int] = (a: Int) => a.toString
implicit val showUser: Show[User] = (u: User) => u.email
}
def render[A](a: A)(implicit s: Show[A]): String = s.show(a)
The reason this beats subtyping is that subtyping forces the decision at definition time, by whoever owns the type. java.lang.String implements Comparable because someone at Sun decided in 1995 that strings have one natural order; you cannot add a second, and you cannot make Int implement an interface you invented last week because you do not own Int and it has no supertype you control. A type class inverts this: the instance is a separate value, so you can define Show[java.time.Instant] for a type from the JDK, define three different Ordering[User] values and choose one per call site, and define instances for final and sealed classes that no one can subclass.
It also solves the binary-method problem cleanly. An interface method def compareTo(other: This) cannot be typed honestly under subtyping -- a Comparable reference gives you no guarantee the argument is the same class. Ordering[A].compare(x: A, y: A) names the type once and both arguments are pinned to it.
The cost is real and worth stating. First, an extra parameter is threaded through every generic function in the chain, and a missing instance anywhere fails the whole call rather than a part of it. Second, and more fundamentally, you lose dynamic dispatch. The instance is selected statically from the compile-time type, so a List[Any] cannot dispatch per element the way a List[Comparable] would -- there is no Show[Any] that magically consults each element's runtime class. If you genuinely need per-element runtime dispatch, subtyping or a reflective lookup is the right tool, not a type class.
Context bounds and the shape of a type class API
def render[A](a: A)(implicit s: Show[A]) says "A must have a Show" in a roundabout way, so Scala offers a context bound as sugar:
def render[A: Show](a: A): String = implicitly[Show[A]].show(a)
// desugars to roughly:
def render[A](a: A)(implicit ev$1: Show[A]): String = ev$1.show(a)
Three consequences fall out of that desugaring and each of them bites someone eventually. The generated parameter has a compiler-chosen name you cannot write, which is exactly why implicitly[Show[A]] exists -- it is a one-line function def implicitly[T](implicit t: T): T = t whose only job is to hand you back the instance the compiler just found. Second, multiple bounds stack: def f[A: Show : Ordering] appends two parameters, and they merge with any implicit list you wrote yourself, so you cannot have both a context bound and a separate hand-written implicit clause -- there is only ever one implicit parameter list, and it must be last.
Third, and this catches library authors: adding a context bound to a published method is a binary-compatibility break. It changes the erased JVM signature by appending a parameter, so code compiled against the old signature gets a NoSuchMethodError at runtime even though the source still compiles. Tools like MiMa flag it; the fix is an overload or a new method name, not a version bump you hope nobody notices.
The other half of type class API design is giving users method syntax rather than function syntax. Nobody wants to write render(user) when user.show reads better, so libraries ship a syntax object combining a type class with an implicit class:
object syntax {
implicit class ShowOps[A](private val a: A) extends AnyVal {
def show(implicit s: Show[A]): String = s.show(a)
}
}
// user code: import myapp.syntax._ then user.show
Note that the implicit parameter is on the method, not the wrapper class -- that keeps ShowOps a legal value class, which the next section explains matters more than it looks.
Extension methods via implicit classes - and what they cost
implicit class is pure sugar. This:
implicit class IntOps(i: Int) { def isEven: Boolean = i % 2 == 0 }
expands to a class plus a conversion into it:
class IntOps(i: Int) { def isEven: Boolean = i % 2 == 0 }
implicit def IntOps(i: Int): IntOps = new IntOps(i)
Which means 5.isEven is a conversion firing at a member-selection site, and it allocates an IntOps object every time. In a tight loop over a large collection that is one short-lived allocation per element -- survivable under a generational collector, but pure waste, and it shows up in allocation profiles of DSL-heavy code as a surprisingly large fraction of total churn.
The fix is to make the wrapper a value class by extending AnyVal. The compiler then erases the wrapper entirely, compiling 5.isEven into a static method call that takes the raw Int. That is genuinely zero-allocation, but value classes carry a list of restrictions you have to design around:
- Exactly one
valparameter in the primary constructor, and no othervals, novars, no nested classes or objects. - No
equalsorhashCodeoverride. - Placement: implicit classes may not be top-level, and value classes must be top-level or a member of a statically accessible object. The two rules intersect, so an
implicit class ... extends AnyValhas exactly one legal home: a top-levelobject. Put one inside a trait or a class and it will not compile.
And the erasure is not unconditional. The wrapper gets boxed back into a real allocation whenever the value class is used as a generic type argument, stored in an Array, put into a collection, matched against in a pattern, or assigned to Any or a supertype. So a value class used only for extension-method syntax is genuinely free, and a value class you put in a List is not. If what you actually want is a zero-cost domain type rather than method syntax, Scala 3's opaque types avoid the boxing cliff entirely.
Implicit conversions: the dangerous one
An implicit conversion is a unary implicit def from A to B. The compiler applies it in two situations, and knowing both is the difference between reading Scala and guessing at it.
Member selection. You write x.foo, x has no member foo, and rather than failing the compiler searches for a conversion whose result type does have a foo. This is the mechanism behind every extension method in the language.
Adaptation. You supply a value of type A where B is expected -- an argument, an assignment, a return position -- and rather than reporting a type error the compiler searches for a conversion from A to B.
The first is useful. The second is the dangerous one, because it removes a type error. The whole value proposition of a static type system is that a mismatch stops the build; a conversion in scope converts that stop into a silent success whose behaviour you now have to reason about. The evidence is invisible: nothing at the call site indicates a conversion ran, and the conversion that ran may have arrived through a wildcard import in a file you have never opened.
You are already using them constantly without noticing, because Predef is auto-imported into every source file and it is full of conversions: ArrowAssoc is why "a" -> 1 builds a tuple, augmentString is why a String appears to have map and filter, and int2Integer and friends are how Java autoboxing is reproduced. These work because they are famous, unambiguous, and one canonical set. Yours will not be.
Two related features are worth recognising and not reaching for. View bounds -- def f[A <% B](a: A), meaning "A, or anything convertible to B" -- were deprecated in Scala 2.11 precisely because they encoded conversions into signatures. And defining a conversion requires import scala.language.implicitConversions or the corresponding -language: flag, a deliberate speed bump.
Where conversions still earn their place: adapting between two libraries' near-identical model types at a single module boundary, and numeric or literal widening inside a small closed DSL. In both cases the conversion should be defined in one narrowly-imported object, never in a package object or a companion where it lands in scope by default.
What Scala 3 did to each job
The redesign is not a rename. Each of the four jobs got a construct that says what it is, and in three cases the semantics changed too:
| Scala 2 | Scala 3 | What actually changed |
|---|---|---|
(implicit x: T) | (using x: T) | Demand is now syntactically distinct from supply; the parameter may be anonymous, since you often only need it forwarded. |
implicit val / implicit def instance | given | Instances may be anonymous and named by type alone; a wildcard import x._ no longer drags them in -- that needs import x.given. |
implicit def a: A => B | given Conversion[A, B] | Opt-in at both ends: it must be an instance of a dedicated type, and callers still need the language import. An ordinary given function can no longer become a conversion by accident. |
implicit class | extension (x: T) def ... | A first-class construct, not sugar for a conversion. No wrapper class, no allocation, no AnyVal gymnastics, and legal at top level. |
implicitly[T] | summon[T] | Rename. |
| Hand-written instances per type | derives | Compile-time structural derivation via Mirror, in the language rather than in a macro library. |
The single most important row is the fourth: separating extension methods from conversions is what finally let the language make conversions rare without making extension methods rare. For the full treatment of the new constructs -- coherence, instance placement, derivation -- see Scala 3 contextual abstraction, and for the Mirror machinery behind derives, Scala 3 metaprogramming.
Where implicits earn their complexity
A useful test: an implicit is justified when the value is determined by the type or constant across a scope, and unjustified when it is merely tedious to type out.
Good: capability threading. An ExecutionContext, a tracing span, a database Session inside a transaction block, a tenant configuration. One value, correct everywhere in the scope, needed by functions that have no business knowing about it. Passing it explicitly through fifteen frames is noise, and there is exactly one right answer at each call site.
Good: type classes. Codecs, orderings, numeric operations, anything where the correct value is a function of the type and nothing else. This is the case implicits were designed for.
Bad: implicits of common types. An implicit timeout: Int or implicit userId: String is a landmine, because resolution keys on the type. Any other library's implicit Int in scope is now a competing candidate, and worse, either one satisfies the parameter -- you can get a successful compile that passes the wrong number. Always wrap in a purpose-built type: final case class Timeout(d: Duration).
Bad: two call sites. If a parameter is used twice, write it twice. The implicit machinery costs the reader more than the two arguments cost the writer.
Bad: conversions for convenience. Covered above -- an explicit .toPath is shorter to read than an invisible conversion is to find.
The summary rule is about the reader, not the writer: if someone has to run the compiler to know which value arrives at a call site, the implicit has gone too far. Explicit passing has a cost that is visible and bounded. Implicit passing has a cost that is invisible and unbounded, and it is paid by whoever maintains the code in two years.
Keeping implicits discoverable, and debugging them
Discoverability is a design property, not a documentation problem, and it comes down to three habits.
Put the canonical instance in the companion object. An instance in object Show or in the companion of the type being shown is found without any import at all, which means users never have to guess which import to add and there is only one candidate to be ambiguous with. Alternative instances go in a separately-named object that callers import deliberately. The resolution rules are specifically designed to make this placement work.
Ship a syntax object, not loose extension methods. One import, one place to look, and users who do not want method syntax are not forced to take it.
Annotate the type class with the error you want. This is the highest-leverage line in a type class library and almost nobody writes it:
@annotation.implicitNotFound(
"No Codec found for ${A}. Import myapp.codecs._ for the built-ins, " +
"or define an implicit Codec[${A}] in its companion object.")
trait Codec[A] { ... }
Without it the user sees "could not find implicit value for parameter c: Codec[Order]". With it they see the fix. The companion annotation @implicitAmbiguous does the same for the case where two of your own instances collide.
When resolution does fail, three techniques do most of the work. Ask for the instance directly -- writing implicitly[Codec[Order]] (or summon in Scala 3) on its own line bisects the problem, because you get an error about that one goal instead of an error about a twelve-level derivation. Turn on the compiler's trace: -Xlog-implicits on Scala 2.12, -Vimplicits on 2.13, -explain on Scala 3, each of which prints why candidates were rejected rather than just that nothing was found. And use the editor: both Metals and IntelliJ can show inserted implicit parameters and conversions as inlay hints, which turns the invisible back into something you can read.
One operational note: derivation-heavy layers -- a codec for every case class in a large domain model -- are frequently the single largest contributor to a Scala project's compile time, because every derived instance is a search tree the compiler walks on every compile. Semi-automatic derivation, where you write one line per type to cache the instance in a val instead of re-deriving it at each use site, is the standard remedy.