Contextual Abstractions in Scala 3: A cleaner approach to implicits
A hands-on look at Scala 3's given, using, summon, and Conversion, with examples that make implicit-heavy code easier to reason about.
If you’ve worked with Scala 2, you’ve probably run into the implicit keyword. It can be
incredibly useful, but it can also make code feel like it’s doing work behind your back.
I’ve had more than one debugging session where the hardest part was figuring out where an
implicit value came from. Scala 3 breaks that old model into a few focused tools called
Contextual Abstractions.
In this post, we’ll go through given, using, summon, and Conversion using examples
from a stock trading domain. The goal is straightforward: understand what each keyword does
and why Scala 3 code is usually easier to read.
The Problem with Scala 2 Implicits
In Scala 2, the implicit keyword handled too many jobs:
- Implicit parameters (dependency injection)
- Implicit conversions (type coercion)
- Implicit classes (extension methods)
- Type class instances
That flexibility came with a cost. In larger codebases, it became hard to trace where values, conversions, and extension methods came from. Consider this Scala 2 stock trading example:
// Scala 2 - The implicit chaos
object TradingApp {
case class Stock(symbol: String, price: BigDecimal)
case class USD(amount: BigDecimal)
case class EUR(amount: BigDecimal)
// Implicit conversion - converts USD to EUR silently
implicit def usdToEur(usd: USD): EUR = EUR(usd.amount * 0.85)
// Implicit parameter - trading context
implicit val tradingContext: TradingContext = new TradingContext {
def marketOpen: Boolean = true
}
// Implicit class - extension methods
implicit class StockOps(stock: Stock) {
def isAffordable(budget: USD)(implicit ctx: TradingContext): Boolean =
ctx.marketOpen && stock.price <= budget.amount
}
def executeOrder(stock: Stock)(implicit ctx: TradingContext): Unit = {
// Where does ctx come from? What conversions might happen?
println(s"Executing order for ${stock.symbol}")
}
}
Looking at executeOrder, can you immediately tell:
- Where
TradingContextcomes from? - What implicit conversions might be triggered?
- Which implicit class methods are available on
Stock?
You can answer those questions, but not at a glance. Scala 3’s design tries to reduce that kind of scavenger hunt.
Given Instances
The given keyword in Scala 3 replaces implicit val and implicit object. Use it to define
default instances the compiler can provide when a matching type is required.
Let’s define our stock trading domain types and create given instances:
// Domain types
case class Stock(symbol: String, name: String, price: BigDecimal, marketCap: Long):
override def toString: String = s"$symbol ($name) @ $$${price}"
enum OrderType:
case Buy, Sell
case class Order(stockSymbol: String, quantity: Int, orderType: OrderType)
case class USD(amount: BigDecimal):
override def toString: String = s"$$${amount} USD"
case class EUR(amount: BigDecimal):
override def toString: String = s"€${amount} EUR"
Let’s start with a given instance for sorting stocks by price:
// Scala 3 - given instance
given stockByPrice: Ordering[Stock] = Ordering.by(_.price)
// Usage
val stocks = List(
Stock("AAPL", "Apple", 178.50, 2800000000000L),
Stock("MSFT", "Microsoft", 378.91, 2810000000000L),
Stock("GOOGL", "Alphabet", 141.80, 1780000000000L)
)
stocks.sorted // Automatically uses stockByPrice
// Result: List(GOOGL at 141.80, AAPL at 178.50, MSFT at 378.91)
Compare this to Scala 2:
// Scala 2
implicit val stockByPrice: Ordering[Stock] = Ordering.by(_.price)
The syntax change looks small, but the intent reads more clearly once you get used to it. You can also define anonymous given instances when the name isn’t important:
// Anonymous given - when you don't need to reference it by name
given Ordering[Stock] = Ordering.by(_.price)
Multiple Given Instances
What if you want to sort stocks by different criteria? To avoid ambiguity errors, keep only one
default given in scope and place alternatives in a separate object:
// Default ordering (only one given in scope to avoid ambiguity)
given stockByPrice: Ordering[Stock] = Ordering.by(_.price)
// Alternative orderings kept in an object - import explicitly when needed
object StockOrderings:
given byMarketCap: Ordering[Stock] = Ordering.by(_.marketCap)
given bySymbol: Ordering[Stock] = Ordering.by(_.symbol)
// Explicitly choose which ordering to use
stocks.sorted(using StockOrderings.byMarketCap)
This pattern avoids the “Ambiguous given instances” error that occurs when multiple givens of the same type are in scope.
Using Clauses
The using keyword replaces implicit parameters. It makes the dependency visible in the function
signature instead of hiding it in the body.
Here’s a trading context our functions can depend on:
trait TradingContext:
def marketOpen: Boolean
def defaultCurrency: String
def maxOrderSize: Int
trait PricingStrategy:
def calculateTotalCost(stock: Stock, quantity: Int): BigDecimal
Now define given instances and functions that use them:
// Define our contexts as given instances
given defaultContext: TradingContext with
def marketOpen: Boolean = true
def defaultCurrency: String = "USD"
def maxOrderSize: Int = 10000
given standardPricing: PricingStrategy with
def calculateTotalCost(stock: Stock, quantity: Int): BigDecimal =
stock.price * quantity * BigDecimal("1.001") // 0.1% trading fee
// Functions that require context via 'using'
def validateOrder(order: Order)(using ctx: TradingContext): Boolean =
ctx.marketOpen && order.quantity <= ctx.maxOrderSize
def executeOrder(order: Order)(using ctx: TradingContext, pricing: PricingStrategy): Unit =
if validateOrder(order) then
println(s"Order validated in ${ctx.defaultCurrency}")
else
println(s"Order validation failed")
The using clause shows exactly which contextual dependencies a function needs. When you call
executeOrder, the compiler supplies matching given instances:
val order = Order("AAPL", 100, OrderType.Buy)
// Compiler automatically supplies TradingContext and PricingStrategy
executeOrder(order)
// Or explicitly pass a different context
executeOrder(order)(using customContext, customPricing)
Context Bounds (Shorthand Syntax)
For type-class style APIs, Scala 3 gives you a shorthand with context bounds:
// These are equivalent:
def sortStocks[T](items: List[T])(using ord: Ordering[T]): List[T] = items.sorted
def sortStocks[T: Ordering](items: List[T]): List[T] = items.sorted
The [T: Ordering] syntax means “T must have an Ordering instance available in context.”
Summoning Instances
Sometimes you need to retrieve a contextual value explicitly. In Scala 2, you’d use
implicitly[T]. In Scala 3, the equivalent is summon[T].
trait RiskCalculator:
def assessRisk(order: Order): String
given defaultRiskCalculator: RiskCalculator with
def assessRisk(order: Order): String =
if order.quantity > 1000 then "HIGH"
else if order.quantity > 100 then "MEDIUM"
else "LOW"
Now use summon to retrieve the instance:
def processOrder(order: Order)(using TradingContext): Unit =
// Summon the RiskCalculator from context
val riskCalc = summon[RiskCalculator]
val riskLevel = riskCalc.assessRisk(order)
println(s"Risk level: $riskLevel")
Compare with Scala 2:
// Scala 2
val riskCalc = implicitly[RiskCalculator]
// Scala 3
val riskCalc = summon[RiskCalculator]
summon is easier to read in code reviews because the call says what it’s doing.
Practical Use Case: Summoning in Generic Code
summon is especially useful in generic code where you need access to type class instances:
def getOrdering[T: Ordering]: Ordering[T] = summon[Ordering[T]]
// Use it to create custom comparisons
val priceOrdering = getOrdering[Stock](using stockByPrice)
Contextual Conversions with Conversion[-T, +U]
Implicit conversions in Scala 2 were powerful, but they could also fire in places you didn’t
expect. Scala 3 introduces Conversion[-T, +U] as a more explicit way to model conversions.
The Problem with Scala 2 Implicit Conversions
// Scala 2 - Dangerous implicit conversion
case class USD(amount: BigDecimal)
case class EUR(amount: BigDecimal)
implicit def usdToEur(usd: USD): EUR = EUR(usd.amount * 0.85)
def payInEuros(amount: EUR): Unit = println(s"Paying ${amount.amount} EUR")
// This compiles silently - USD is converted to EUR without warning!
payInEuros(USD(100))
In a trading system, silent currency conversion is usually not what you want.
Scala 3’s Safer Approach
In Scala 3, you opt into conversions explicitly with scala.Conversion:
import scala.language.implicitConversions
case class USD(amount: BigDecimal)
case class EUR(amount: BigDecimal)
// Explicit conversion with exchange rate
given usdToEur: Conversion[USD, EUR] with
def apply(usd: USD): EUR = EUR(usd.amount * BigDecimal("0.85"))
Key differences:
- You must import
scala.language.implicitConversionsto enable conversions - The
Conversiontrait makes the intent explicit - Conversions are defined as given instances, making them discoverable
Using Conversions in Practice
def processEuroPayment(amount: EUR): Unit =
println(s"Processing payment: ${amount.amount} EUR")
// With the given Conversion in scope:
val dollars = USD(BigDecimal("100.00"))
processEuroPayment(dollars) // Converted automatically: Processing payment: 85.00 EUR
Bidirectional Conversions
For a complete currency conversion system:
object CurrencyConversions:
private val usdToEurRate = BigDecimal("0.85")
private val eurToUsdRate = BigDecimal("1.18")
given Conversion[USD, EUR] =
usd => EUR((usd.amount * usdToEurRate).setScale(2, BigDecimal.RoundingMode.HALF_UP))
given Conversion[EUR, USD] =
eur => USD((eur.amount * eurToUsdRate).setScale(2, BigDecimal.RoundingMode.HALF_UP))
// Usage
import CurrencyConversions.given
val inDollars: USD = USD(100)
val inEuros: EUR = inDollars // Automatic conversion
val backToDollars: USD = inEuros // And back
Type-Safe Order Conversions
Another practical example: converting order requests to executed trades.
import java.time.Instant
case class OrderRequest(symbol: String, quantity: Int, orderType: OrderType)
case class ExecutedOrder(
symbol: String,
quantity: Int,
orderType: OrderType,
executedAt: Instant,
status: String
)
given Conversion[OrderRequest, ExecutedOrder] with
def apply(req: OrderRequest): ExecutedOrder =
ExecutedOrder(
symbol = req.symbol,
quantity = req.quantity,
orderType = req.orderType,
executedAt = Instant.now(),
status = "EXECUTED"
)
def recordTrade(executed: ExecutedOrder): Unit =
println(s"Recorded: ${executed.symbol} - ${executed.status}")
// Convert and record in one step
val request = OrderRequest("AAPL", 50, OrderType.Buy)
recordTrade(request) // Automatic conversion to ExecutedOrder
What feels better in day-to-day use
Scala 3’s contextual abstractions do more than tidy up syntax. Splitting one overloaded feature into focused constructs makes unfamiliar code easier to understand.
Clearer intent and separation of concerns
Each concept now has its own keyword:
| Purpose | Scala 2 | Scala 3 |
|---|---|---|
| Canonical values | implicit val/object | given |
| Context parameters | implicit parameter | using clause |
| Retrieve from context | implicitly[T] | summon[T] |
| Type conversions | implicit def | given Conversion[A, B] |
| Extension methods | implicit class | extension |
This split helps when scanning code. Seeing given or using immediately tells you
what role that line plays.
Better compiler error messages
Scala 3’s compiler usually gives clearer error messages for contextual abstractions. When a required given instance is missing:
def placeOrder(order: Order)(using ctx: TradingContext): Unit = ???
placeOrder(Order("AAPL", 100, OrderType.Buy))
// Error: No given instance of type TradingContext was found for parameter ctx
The message points to exactly what is missing and where.
Better IDE support and discoverability
Modern IDEs like IntelliJ IDEA and Metals can now:
- Show which given instances are in scope
- Navigate directly to given definitions
- Suggest imports for missing given instances
- Display parameter info showing
usingrequirements
In practice, this means less guesswork around imports and available instances.
Migration path from Scala 2
Scala 3 gives you a practical migration path. The old implicit syntax still works with
deprecation warnings, so you can migrate gradually:
// This still works in Scala 3 (with warnings)
implicit val oldStyle: Ordering[Stock] = Ordering.by(_.price)
// Recommended Scala 3 style
given newStyle: Ordering[Stock] = Ordering.by(_.price)
Summary
Scala 3’s contextual abstractions clean up the old implicit model:
given- Defines canonical type class instances with clear intentusing- Declares context dependencies explicitly in function signaturessummon- Retrieves contextual values with a descriptive nameConversion- Provides type-safe, opt-in implicit conversions
These changes make Scala code easier to read and debug, especially once your project grows. The stock trading examples above show how the features fit together in realistic code.
If you’re migrating from Scala 2, the transition is straightforward. Start by replacing
implicit val with given and implicit parameters with using clauses. Your code
will become clearer.
For more details, check out the official Scala 3 documentation on Contextual Abstractions.
Update [2025-12-15]: Code samples were updated for the latest Scala 3 release.