Inline

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Scala 3.10 (3.10.0, released 2026-10-12) and every example was compiled and run with Scala 3.10.0 through Scala CLI on JDK 26 (checked on 2026-10-16). inline is part of Scala 3 since 3.0 and nothing on this page is experimental or in preview, so everything applies to the 3.3 LTS and 3.9 LTS lines. The main examples were also run on 3.3.8 with the same output. The examples use the trailhead scenario, a toolkit that plans hikes on a network of mountain trails.

inline is the first layer of Scala 3 metaprogramming, described on Metaprogramming Overview. It is a soft modifier that guarantees that a definition is expanded at the point of use. It is the base for the compile-time helpers on Compile-Time Operations, for macros (Macros: Quotes & Splices) and for type class derivation (Type Class Derivation). Used alone, it removes call overhead, removes dead branches and makes the compiler compute with constants.

inline values

An inline val is a constant. Its type is the literal type of the right-hand side, and every use is replaced by the value.

//> using scala 3.10.0

package trailhead

object Config:
  inline val MaxGrade = 6           // a constant: the literal type 6
  inline val DebugLogging = false

  val maxGradeVal: Int = 6          // an ordinary val: type Int, not a constant

@main def run(): Unit =
  val g: 6 = Config.MaxGrade        // compiles: MaxGrade has the singleton type 6
  println(g)

The right-hand side must be a constant expression. Both of the following are rejected, and the second shows that even a literal does not help when the declared type is Int rather than the literal type (verified on 3.10.0):

object Config:
  val maxGradeVal: Int = 6
  inline val Computed = maxGradeVal + 1    // right-hand side is not a constant
  inline val Limit: Int = 3                // declared type is Int, not a literal type
-- Error: e1.scala:5:24
5 |  inline val Computed = maxGradeVal + 1
  |                        ^^^^^^^^^^^^^^^
  |                        inline value must have a literal constant type
-- Error: e2.scala:4:20
4 |  inline val Limit: Int = 3
  |                    ^^^
  |                    inline value must have a literal constant type

final val X = 6, with no type annotation, is also a constant in Scala 3 and gets the literal type 6. A plain val Y = 6 gets the type Int, and val b: 6 = C.Y fails with Found: (C.Y : Int) Required: (6 : Int). The Reference says final for constants is still supported; use inline val in new code because it states the intent and it fails to compile when the value is not constant.

inline methods

An inline def is expanded at every call site. The Reference states that, unlike @inline in Scala 2, the expansion is guaranteed, not best effort.

//> using scala 3.10.0

package trailhead

inline def power(x: Double, inline n: Int): Double =
  inline if n == 0 then 1.0
  else inline if n % 2 == 1 then x * power(x, n - 1)
  else
    val y = x * x
    power(y, n / 2)

def cube(d: Double): Double = power(d, 3)

@main def run(): Unit =
  println(power(2.0, 10))
  println(cube(1.5))
1024.0
3.375

Printing the trees after the inlining phase with -Vprint:inlining (passed through Scala CLI as -O -Vprint:inlining) shows that cube contains no call and no loop. The recursion over the constant exponent was unrolled:

    def cube(d: Double): Double =
      (d *
        {
          val y: Double = (d * d)
          (y * 1.0d):Double
        }:Double
      ):Double

Three observations from this run:

  • cube(d) is d * (d * d) * 1.0. The compiler did the control flow (inline if) and left only the arithmetic.

  • The val y is kept, because y is used more than once in the expanded body and the argument expression must be evaluated once.

  • An inline def that is not an override leaves no method in the compiled class. javap -p on a class with inline def bump(): Int shows only the field and the constructor, not bump. The body is stored in the TASTy file, which is what a client compiled later reads (see Metaprogramming Overview).

Private members

An inline method can use private members of its class, even though its expansion is outside the class. The compiler adds a public accessor for each such member. For this class:

class Counter:
  private var n = 0
  inline def bump(): Int = { n += 1; n }

javap -p shows the generated forwarders (verified on 3.10.0):

public class Counter {
  private int n;
  public Counter();
  public final int Counter$$inline$n();
  public final void Counter$$inline$n_$eq(int);
}

Counter$$inline$n and its setter exist only to serve expansions, and they become part of the binary interface of the class. @publicInBinary is not accepted on a private definition (error @publicInBinary cannot be used on private definitions, with the hint to use private[Counter] or protected), so private state used by a public inline method is always exposed through these accessors.

inline parameters

A parameter of an inline method can itself be inline. The argument is then substituted into the body, like a by-name parameter that is allowed to be duplicated or inspected at compile time. The three parameter kinds differ in how often the argument runs. The program below counts evaluations of an expensive argument:

//> using scala 3.10.0

package trailhead

object Config:
  inline val DebugLogging = false

inline def logged(inline message: String): Unit =
  inline if Config.DebugLogging then println(s"[debug] $message")

var evaluations = 0
def expensive(): String =
  evaluations += 1
  "ascent profile"

inline def twiceInline(inline s: String): String = s + " / " + s
inline def twiceBound(s: String): String = s + " / " + s   // an ordinary by-value parameter

@main def run(): Unit =
  logged(expensive())                  // DebugLogging is false: the whole call vanishes
  println(s"evaluations after logged: $evaluations")

  println(twiceInline(expensive()))
  println(s"evaluations after inline parameter: $evaluations")
  evaluations = 0
  println(twiceBound(expensive()))
  println(s"evaluations after ordinary parameter of an inline def: $evaluations")
evaluations after logged: 0
ascent profile / ascent profile
evaluations after inline parameter: 2
ascent profile / ascent profile
evaluations after ordinary parameter of an inline def: 1
  • A by-value parameter (no modifier) is evaluated once, before the body, as in any method.

  • A by-name parameter (=> T) is evaluated at each use in the body, and if it is used exactly once the code is inlined with no closure.

  • An inline parameter is substituted at each use. The argument expensive() ran twice, once per s, and it did not run at all in logged, because the inline if on a constant condition removed the only use.

Inline parameters have a second purpose, which is to carry constants into the body so that inline if, inline match and the helpers on Compile-Time Operations can see them. inline n: Int in power above is what made inline if n == 0 decidable.

A function-typed inline parameter is beta-reduced. In the next program f is a lambda, and no Function1 object is allocated:

//> using scala 3.10.0

package trailhead

case class Trail(name: String, lengthKm: Double, ascentM: Int)

inline def sumBy(trails: List[Trail])(inline f: Trail => Double): Double =
  var total = 0.0
  var rest = trails
  while rest.nonEmpty do
    total += f(rest.head)       // f is inlined here: no Function1 is allocated
    rest = rest.tail
  total

@main def run(): Unit =
  val trails = List(Trail("Ridge Loop", 12.5, 900), Trail("Lake Path", 4.0, 120))
  println(sumBy(trails)(_.lengthKm))
  println(sumBy(trails)(t => t.ascentM / 1000.0))
16.5
1.02

javap -p -c on the compiled run method shows no invokedynamic and no reference to Function1 or sumBy: the loop was copied into run and f(rest.head) became rest.head.lengthKm.

inline if

inline if requires its condition to reduce to a constant at compile time and then keeps only the chosen branch. It is checked: if the condition is not a constant the compiler reports it.

inline def kind(n: Int): String =
  inline if n > 3 then "steep" else "gentle"

def f(k: Int): String = kind(k)
-- Error: t5.scala:4:28
4 |def f(k: Int): String = kind(k)
  |                        ^^^^^^^
  |Cannot reduce `inline if` because its condition is not a constant value: k > 3

A plain if in an inline method is also simplified when its condition happens to be constant after expansion, as in the Logger example of the Reference, but only inline if makes this a requirement. Use inline if when the branch is a contract (a type or an error depends on it), and plain if when the simplification is only an optimization.

inline match

inline match selects a case at compile time. The scrutinee must reduce to a constant or a statically known term, the compiler tries the patterns in order, and only the right-hand side of the first match is kept. When no case can be chosen the compiler reports an error:

inline def kind(inline n: Int): String =
  inline n match
    case 0 => "flat"
    case 1 => "gentle"

def runtimeGrade(k: Int): String = kind(k)
@main def run(): Unit = println(kind(2))
-- Error: t3.scala:7:39
7 |def runtimeGrade(k: Int): String = kind(k)
  |                                   ^^^^^^^
  |                                   cannot reduce inline match with
  |                                    scrutinee:  k : (k : Int)
  |                                    patterns :  case 0
  |                                                case 1
-- Error: t4.scala:7:36
7 |@main def run(): Unit = println(kind(2))
  |                                ^^^^^^^
  |                                cannot reduce inline match with
  |                                 scrutinee:  2 : (2 : Int)
  |                                 patterns :  case 0
  |                                             case 1

The first call fails because k is a runtime value, the second because the constant 2 matches no case, and there is no default. Add a case _ to make the match total.

The most important use of inline match is on a type. erasedValue[T] stands for a value of type T and is allowed only as the scrutinee of an inline match; the patterns are then type patterns. The example below returns a zero value chosen by the type argument, and it returns a precise type (the next section explains why):

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

Type matching is the technique used by derived methods on Type Class Derivation and is described in more detail on Compile-Time Operations.

transparent inline

An ordinary inline method has the return type that is written in its signature, and its callers are typed against that signature. A transparent inline method has its result type refined to the type of the expansion. The signature is only an upper bound, and each call can have a different, more precise type.

//> using scala 3.10.0

package trailhead

// transparent: the result type is whatever the expansion has
transparent inline def label(inline d: Int): Any =
  inline d match
    case 0 => "flat"
    case 1 => 42
    case _ => List(d)

inline def labelOpaque(inline d: Int): Any =
  inline d match
    case 0 => "flat"
    case 1 => 42
    case _ => List(d)

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

@main def run(): Unit =
  val a = label(0)
  val s: String = label(0)               // compiles: the type of label(0) is String
  val n: Int = label(1)
  println((a, s, n))
  println(labelOpaque(0))
  println(zero[Int] + 1)
  println(zero[String].length)
(flat,flat,42)
flat
1
0

val s: String = label(0) compiles because label(0) has the type String, even though label is declared to return Any. With the non-transparent labelOpaque the same assignment fails:

-- [E007] Type Mismatch Error: t2.scala:6:29
6 |val bad: String = labelOpaque(0)
  |                  ^^^^^^^^^^^^^^
  |                  Found:    Any
  |                  Required: String

Transparency is how type-level programming and implicit-style resolution work in Scala 3: the expansion can compute a type that the signature cannot name. The cost is that the signature no longer tells callers what they get. Prefer a plain inline method with a precise declared type, and use a match type (see Match Types) when the return type depends on a type argument. Reserve transparent for the cases where the result type depends on a value or on the shape of the argument.

Overriding and abstract inline methods

Rules from the Reference, each checked on 3.10.0:

  • An inline method may override or implement a non-inline method. It is then also kept as a real method, so dynamic dispatch works:

trait Planner:
  def plan(km: Double): String = "base"
class A extends Planner:
  inline override def plan(km: Double): String = "inlined"
@main def run(): Unit =
  val p: Planner = A()
  println(p.plan(1))      // dynamic call through the trait
  println(A().plan(1))    // inlined call
inlined
inlined
  • Inline methods are effectively final. A subclass of A that overrides plan fails with method plan of type (km: Double): String cannot override final member method plan in class A (error E164).

  • An inline method may be abstract, and it is implemented only by another inline method. It cannot be called through the abstract type:

-- Error: t7.scala:9:16
9 |  println(p.plan(2.5))
  |          ^^^^^^^^^^^
  |          Deferred inline method plan in trait Planner cannot be invoked

That error is for val p: Planner = A(); p.plan(2.5) with inline def plan(inline km: Double): String declared in Planner and implemented in A. Calling A().plan(1.5) compiles.

  • inline on a parameter is only allowed for parameters of inline methods. def h(inline y: Int) = y is rejected with inline modifier can only be used for parameters of inline methods.

An inline given works as a given whose body is inlined at each use:

trait Show[A]:
  extension (a: A) def show: String
inline given Show[Int]:
  extension (a: Int) def show: String = s"int $a"
@main def run(): Unit = println(3.show)
int 3

Recursion and -Xmax-inlines

Inline methods can be recursive, as power showed. The compiler stops an expansion that goes deeper than 32 successive inlines, which protects against infinite expansion. A recursive inline method without an inline parameter whose argument is a runtime value never stops, and the error says so:

inline def countDown(n: Int): Int = if n == 0 then 0 else countDown(n - 1)
def depth(k: Int): Int = countDown(k)
-- Error: e3.scala:3:34
3 |def depth(k: Int): Int = countDown(k)
  |                         ^^^^^^^^^^^^
  |                       Maximal number of successive inlines (32) exceeded,
  |                       Maybe this is caused by a recursive inline method?
  |                       You can use -Xmax-inlines to change the limit.

The message is followed by one "Inline stack trace" entry per level, 32 in all, which is why this error fills the screen. The legitimate case is a recursion on a constant that is deeper than 32:

//> using scala 3.10.0
//> using option -Xmax-inlines:100
inline def countDown(inline n: Int): Int = inline if n == 0 then 0 else countDown(n - 1)
@main def run(): Unit = println(countDown(80))
0

With the same program and countDown(200) the compiler reports Maximal number of successive inlines (100) exceeded. With a limit of 600 and countDown(500) the compiler failed differently, with Recursion limit exceeded. Maybe there is an illegal cyclic reference?, which is the compiler’s own stack limit. Raising -Xmax-inlines gives only a small margin: deep recursion over a constant is better done with a loop in a macro (see Macros: Quotes & Splices) or at run time.

The option is given to Scala CLI with //> using option -Xmax-inlines:100, and in sbt as scalacOptions += "-Xmax-inlines:100".

When to use inline

  • Constants: inline val for configuration flags that must be known at compile time, such as a debug flag.

  • Removing overhead: small methods taking function arguments (loops, resource handling) where avoiding the closure matters. Measure first; the JIT often inlines small methods already.

  • Compile-time checks: inline if or inline match plus error to reject bad arguments (see Compile-Time Operations).

  • Type-level computation: transparent inline with inline match and erasedValue.

  • As the entry point of a macro: every Scala 3 macro is an inline def.

Costs to weigh:

  • Code size grows at each call site. Inlining a large body into many call sites enlarges the bytecode.

  • The body is part of the binary interface. A client compiled against version 1 of a library carries a copy of the inline body; changing the body does not change that client until it is recompiled. Treat a change of an inline method as a binary change.

  • Errors report an "Inline stack trace", with the call site first and the lines of the inline method below it.

  • Using an inline method as a function value does not inline it. val f = twice for inline def twice(x: Int): Int compiles on 3.10.0 (it prints 4 for f(2)), but f is then an ordinary function value, and its calls are not expanded at the places where f is called.

Comparison with other languages

  • Java has no inline modifier. The JIT compiler decides, at run time, which calls to inline, and a static final field with a constant initializer is a compile-time constant that javac copies into the class files that use it. That is the same mechanism as inline val. See Lambdas and Method References for what Java does with function arguments.

  • Kotlin has inline fun, which copies the function and its lambda arguments into the call site, with noinline and crossinline to opt parameters out of the rule, and reified type parameters that make a type argument available in the body. Scala’s inline parameters correspond to Kotlin’s lambda parameters of an inline fun, and erasedValue plays the role of reified. Kotlin’s const val corresponds to inline val. Scala goes further because inline if and inline match evaluate at compile time.

  • Scala 2 has final val for constants (still valid in Scala 3), the @inline annotation (a hint to the optimizer, still accepted by the Scala 3 compiler) and macros for everything else. There was no way to ask for guaranteed inlining.

Where the books are outdated

  • Books for Scala 2 describe @inline as the way to request inlining. In Scala 3, inline is a modifier that always expands; @inline is only a hint.

  • final val constants are described as the way to define compile-time constants. They still work, but inline val checks that the right-hand side is constant.

  • Some Scala 3 tutorials from the first releases say that inlining happens in the typer phase. On Scala 3.10.0 -Vprint:typer still shows twice(a) for a call to inline def twice(inline x: Int) = x + x, and -Vprint:inlining shows a + a. The phase list from -Vphases has inlining ("inline and execute macros") after pickler.