Migrate Scala 3 Type Classes to given/using with Explicit Imports
Make Scala 3 type class resolution explicit with given instances and using parameters. A task guide covering prerequisites, migration steps, checks and recovery for predictable imports.
15 Dec 2025, 04:30 UTC

The problem with hidden implicits
Implicit-heavy Scala 2 codebases often fail late with ambiguous implicit errors that are hard to trace to an import site. The useful takeaway is to make type class resolution explicit: define the type class once, publish given instances in named objects, and import those instances only where they are needed. Using explicit using parameters removes hidden magic at call sites.
Desired outcome
Predictable type class resolution with import sites you can audit, reduced ambiguity, and clearer call sites. Domain operations such as formatting, validation or serialization should resolve to a single given instance that is visible in the file where it is used.
Prerequisites
A Scala 3 compiler and sbt build. The codebase should already use Scala 3 syntax. Team familiarity with the type class pattern is required. Identify implicit-heavy modules before migration, for example files that define implicit val or implicit def for type class instances.
Given/using is Scala 3 only. Mixing with Scala 2 libraries that rely on implicits can create resolution gaps and may require compatibility shims. Binary compatibility is broken between Scala 2 implicit signatures and Scala 3 given signatures, so cross-building needs separate modules.
Focused procedure
1. Define the type class trait
Keep the trait stable and free of implicits.
trait Formatter[A]:\n def format(a: A): String
A syntax object can provide a using-based extension.
object Formatter:\n extension [A](a: A)(using f: Formatter[A])\n def formatted: String = f.format(a)
2. Provide given instances in dedicated objects
Move instances out of companion objects into named modules to control scope.
object Formatters:\n given Formatter[String] with\n def format(a: String): String = a\n\n given Formatter[Int] with\n def format(a: Int): String = a.toString
Use given instead of implicit for the instance definition. The instance is now a first-class value.
3. Import instances at call sites
Replace wildcard implicit imports with explicit given imports.
import Formatters.given Formatter[String]\nimport Formatters.given Formatter[Int]
Keep imports narrow. Overly broad given imports can reintroduce hidden ambiguity similar to implicits.
4. Use using for explicit passing
When the chosen instance is not obvious, pass it explicitly with using.
def printAll[A](items: List[A])(using fmt: Formatter[A]): Unit =\n items.foreach(i => println(i.formatted))\n\nval customInt: Formatter[Int] = new Formatter[Int]:\n def format(a: Int): String = s\"[$a]\"\n\nprintAll(List(1,2,3))(using customInt)
The using parameter makes the dependency visible in the signature and at the call site.
5. Build configuration check
Run from the project root in a terminal with read access to the source tree.
sbt \"compile\"
Expected check is clean compilation with no ambiguous given errors and no missing given errors. Risk is a compile failure if an implicit is still referenced where a given is now required.
Expected checks
- Compile the module with scalac via sbt and confirm zero errors and warnings about ambiguous or missing given instances.
- Run the existing test suite with
sbt testand verify type class behavior is unchanged by comparing outputs before and after migration. - Use the IDE go-to-definition on a using call to confirm it resolves to the intended given instance object.
- Apply a scalafix rule for implicit to given migration and ensure the report shows no remaining implicit definitions in the target files.
Recovery options
The operation changes source files, so keep a revert point. If ambiguity appears, narrow the import scope or move the conflicting given to a more local definition.
Temporary recovery steps:
- Revert a given instance to a locally scoped definition inside the function to resolve ambiguity.
- Widen import scope temporarily while the instance design is refined, then narrow again.
- Fall back to explicit using parameters at call sites while the instance design is refined.
Rollback is a git revert of the migrated files if resolution cannot be stabilized without broader design changes.
Limitations
Given resolution follows Scala 3 rules and does not automatically find Scala 2 implicit instances. Instance scope is still lexical, so imports must be correct. Large codebases benefit from migrating module by module rather than a full rewrite.
Check the result practically by opening a file that uses the type class and verifying the import list contains the expected given imports and no wildcard implicit imports remain.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.