Adding inline to a Scala 3 method makes the compiler paste its body into every call site. Adding transparent in front changes something else: the type of the call. A transparent inline method's result type is not fixed by its signature. It's whatever the expanded body turns out to be, which can be more precise than the declared type. One keyword turns a method into a small compile-time type function.

This page is about that one keyword: what it changes in typing, when the expansion happens and why that matters, how transparent inline givens change implicit search, when a match type is the better tool, and what it costs in builds and compatibility. It assumes you know inline, inline if, inline match and the scala.compiletime operations. Those are covered in Scala metaprogramming. The examples stay close to the patterns in the Scala 3 reference.

One keyword: blackbox versus whitebox

The Scala 3 reference calls the two flavours blackbox and whitebox. A normal inline method is blackbox: callers see exactly the declared result type, whatever the body does. A transparent inline method is whitebox: after expansion, the call takes the type of the expanded code. The declared type is only an upper bound.

import scala.compiletime.erasedValue

inline def zeroOpaque[T]: Any = inline erasedValue[T] match
  case _: Int    => 0
  case _: String => ""

transparent inline def zero[T]: Any = inline erasedValue[T] match
  case _: Int    => 0
  case _: String => ""

val a = zeroOpaque[Int]   // a: Any   - the signature wins
val b = zero[Int]         // b: Int   - the expansion wins
val n = b + 1             // compiles
// zeroOpaque[Int] + 1    // error: + is not a member of Any

Both methods produce the same bytecode: the literal 0 at the call site. The difference is entirely in what the type checker believes. This is why transparent inline is used for APIs whose result type depends on their inputs: selecting a field by a literal name, choosing an implementation from a constant flag, or computing a tuple shape from a type.

When the expansion happens, and why it matters

The reference states the key rule: transparent inline methods must be expanded during type checking, while other inline methods are inlined later, after the program is fully typed. Everything that makes transparent inline powerful, and most of what makes it awkward, follows from this.

Parsersource to treesTypertypes every expressionInlining phaseafter typingErasure, backendbytecode, TASTytransparent inlineexpanded here, while typingplain inlineexpanded hereresult type taken from the expansiondeclared type keptBecause transparent calls expand inside the typer, their types feed overload resolution, implicit search and inference for the code around them.Plain inline calls are typed against their signature first and expanded only after the whole program has types.
Where each kind of inline call is expanded. Transparent calls expand inside the typer, so the expansion's type takes part in typing the surrounding code.

Because expansion happens inside the typer, the refined type is visible to the expression around the call: overload resolution, implicit search for the next call, and inference of the val's type all see it. It also means expansion runs at a point where the arguments' types may still be being inferred, so a transparent call is sensitive to context in a way a blackbox call is not. Adding a type ascription at the call site can change what it expands to.

It also changes what an inline if inside it does. Within a transparent method, the reference says an inline if forces inlining of any inline definition in its condition during type checking. So nested inline helpers in the condition must reduce to constants right there. If they can't, the call fails to compile rather than falling back to a runtime branch.

Worked example: a factory that returns the precise class

Here is a worked example. A storage layer has two implementations with different extra capabilities: the in-memory store can be cleared, and the disk store can be fsynced. Tests want the memory store's clear, and production code wants fsync. With an ordinary factory returning Store, every caller must downcast. With a transparent factory and a constant flag, each call site gets the precise class, with no cast.

trait Store:
  def put(key: String, value: Array[Byte]): Unit

final class MemStore extends Store:
  def put(key: String, value: Array[Byte]): Unit = ()
  def clear(): Unit = ()

final class DiskStore(path: String) extends Store:
  def put(key: String, value: Array[Byte]): Unit = ()
  def fsync(): Unit = ()

transparent inline def store(inline durable: Boolean): Store =
  inline if durable then DiskStore("/var/data") else MemStore()

val prod = store(true)     // prod: DiskStore
prod.fsync()               // compiles
val test = store(false)    // test: MemStore
test.clear()               // compiles
// test.fsync()            // error: fsync is not a member of MemStore

val flag = sys.env.contains("DURABLE")
// store(flag)             // error: the inline if cannot be reduced, flag is not a constant

Trace one call. At store(true) the typer expands the body, substitutes the inline parameter, and reduces inline if true to its then-branch. The expression is now DiskStore("/var/data"), of type DiskStore, and that becomes the type of prod. The declared Store is checked only as an upper bound. The last line shows the boundary: a value known only at run time can't drive the selection, and the compiler refuses rather than guessing. If you need a runtime choice, write an ordinary method that returns Store. Transparent inline only helps when the choice is static.

Choosing a value by type

The commonest real use is choosing a value by type, with inline match on erasedValue[T]. The reference's rule is that an inline match in a transparent method reduces to the selected branch, and the call gets that branch's type. If no case can be selected from the static type, compilation fails, which turns "unsupported type" into a compile error rather than a runtime exception.

import scala.compiletime.{erasedValue, error}

final class IntCodec:
  def decode(s: String): Int = s.toInt

final class BoolCodec:
  def decode(s: String): Boolean = s.toBoolean

transparent inline def codecFor[T]: AnyRef = inline erasedValue[T] match
  case _: Int     => IntCodec()
  case _: Boolean => BoolCodec()
  case _          => error("no codec for this type")

val port: Int     = codecFor[Int].decode("8080")       // IntCodec#decode: Int
val tls: Boolean  = codecFor[Boolean].decode("true")   // BoolCodec#decode: Boolean
// codecFor[Double]                                    // error: no codec for this type

Without transparent, codecFor[Int] would be typed as AnyRef and the .decode call would not compile. The usual non-macro alternative is a type class, Codec[T] with given instances, and for open-ended sets of types that's still the better design: users can add instances, while the match above is closed. Transparent inline wins when the set is closed and the result types differ in shape, not just in a type parameter.

Transparent inline givens

Givens can be inline too, and here transparent changes the semantics of implicit search, not just a type. The reference states that if an error is reported while inlining a transparent inline given, it counts as an implicit search mismatch and the search continues. With a non-transparent inline given, the error is reported as usual. A transparent given can therefore act as a guard that politely declines.

import scala.compiletime.{constValue, error}

trait Codec[T <: Tuple]:
  def name: String

trait LowPriorityCodecs:
  given general[T <: Tuple]: Codec[T] = new Codec[T] { def name = "general" }

object Codec extends LowPriorityCodecs:
  // Preferred, but only for tuples of up to 3 elements.
  transparent inline given compact[T <: Tuple]: Codec[T] =
    inline if constValue[Tuple.Size[T]] > 3 then error("too wide for compact")
    else new Codec[T] { def name = "compact" }

summon[Codec[(Int, String)]].name               // "compact"
summon[Codec[(Int, Int, Int, Int, Int)]].name   // "general": compact declined

For the five-element tuple, search tries compact first because it has higher priority. Its expansion hits error, so search treats it as not matching and falls through to general. Remove transparent and the same program fails with "too wide for compact", because a blackbox inline given that is selected and then fails stops the search. Use this to express a fast path with a compile-time precondition. Keep the guard cheap, since every implicit search that reaches the given pays for an expansion attempt.

Transparent inline versus the alternatives

ToolHow the result type is knownPick it when
OverloadingChosen from declared argument typesA handful of fixed argument types; no constants involved
Match typeComputed by a type-level function in the signatureThe type relationship should be documented and checked at the definition
Plain inlineDeclared type, alwaysYou want specialisation or constant folding but a stable API type
Transparent inlineThe type of the expansion at each call siteResult type depends on constants or on T, and spelling that as a match type is impractical
Quoted macroComputed by code that inspects treesReal logic over user code, such as parsing a literal or inspecting a class's members

Prefer the option nearest the top that does the job. A match type puts the input-to-output relationship in the signature, where readers, IDEs and later compiler versions can see it without running anything. A transparent method hides it in its body, so the only way to know a call's type is to expand it. When you do reach for transparent, choose a declared result type that's as narrow as you can honestly give: it documents intent and catches expansions that escape the bound. Macros are covered in Scala 3 macros.

Debugging expansions

When a transparent call does something surprising, look at the expansion. Since it happens in the typer, -Xprint:typer shows the code after expansion, with the inferred types. Hovering in an IDE shows the refined type of the val.

  • Recursion limit. Recursive inline methods, such as walking a tuple type one element at a time, can hit "Maximal number of successive inlines (32) exceeded". The limit is set by -Xmax-inlines. Raise it only after making sure the recursion terminates: a missing base case looks the same as a long tuple.
  • Errors at the call site. An error(...) inside the body is reported where the method was called, which is usually what you want. Write messages for the caller, and use codeOf to quote their argument.
  • Unreducible conditions. "Cannot reduce inline if" or an inline match with no matching case means the input is not static enough. Check whether the argument was declared inline and whether the type was still abstract at the call site.
  • Effectively final. The reference says inline methods are effectively final, so you can't specialise a transparent method in a subclass. Put the variation into the match.

Costs: build time, compatibility and readability

Transparent inline has costs that ordinary code doesn't. Build time: the body is expanded and re-typed at every call site, so a widely used transparent helper multiplies typer work. Recursive ones can dominate a module's compile. Measure with your build tool's per-file timings before and after.

Binary and source compatibility: inline bodies are stored in TASTy and copied into callers. Changing the body changes nothing for code compiled earlier until it's recompiled. With a transparent method there's a second effect: changing the body can change the types callers inferred. A library release that "only fixes an implementation" can then break downstream source code that relied on the old refined type. Treat a transparent method's possible result types as part of your public API.

Incremental compilation: a change to the body invalidates every file that calls it, not just the ones whose signatures used it. In a large codebase, keep transparent helpers in a small, stable module.

Readability: a reader can't know a call's type from the signature. Name the methods so the refinement is predictable, and add a test that pins the expected types with explicit ascriptions such as val x: DiskStore = store(true). If someone changes the body, the test fails to compile.

Failure modes

  • Leaking implementation types. A transparent factory exposes DiskStore to callers, who then depend on it. Narrow the expansion with a type ascription in the body if only a capability should be visible.
  • Accidental widening. A val with an explicit wider type, or a passage through a generic method, discards the refinement, and later member calls fail far from the cause.
  • Runtime values sneaking in. Someone passes a config flag instead of a literal, and the build breaks with an unreducible inline if. Document which parameters must be constants by marking them inline.
  • Ambiguous givens. Two transparent givens of equal priority that both succeed cause ambiguity, not fallback. Only failure falls through, so order candidates with priority traits.
  • Confusing it with transparent traits. transparent trait is a different feature, a hint that keeps marker traits out of inferred types. It has nothing to do with inlining.

What to do next

  1. Find your casts after factory or lookup calls. Each one is a candidate for a transparent inline method or, better, a match type.
  2. For each candidate, try a match type or overloading first, and use transparent inline only when those can't express the result.
  3. Give every transparent method the narrowest honest declared type, and mark its constant parameters inline.
  4. Add a compile-time test with explicit type ascriptions for the refined results, plus a negative test that an unsupported input fails to compile (see Scala 3 overview for the language features involved).
  5. Use transparent inline givens only as guarded fast paths with a lower-priority general instance; see Scala 3 givens.
  6. Keep transparent helpers in a stable module, measure their compile cost, and review body changes as API changes.
Key takeaway: A transparent inline method is expanded while the compiler is still typing your program, and the call takes the type of the expansion rather than the declared result type. That lets one method return a precise class or value type chosen by constants or by a type parameter, and lets a transparent given decline so implicit search moves on. The price is result types that are invisible in signatures, more compile work at every call site, and body changes that can break callers' types, so prefer match types or overloading when they suffice and pin refined types with tests.