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.
Source: Scala 3 Reference: Inline
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)isd * (d * d) * 1.0. The compiler did the control flow (inline if) and left only the arithmetic. -
The
val yis kept, becauseyis used more than once in the expanded body and the argument expression must be evaluated once. -
An
inline defthat is not an override leaves no method in the compiled class.javap -pon a class withinline def bump(): Intshows only the field and the constructor, notbump. The body is stored in the TASTy file, which is what a client compiled later reads (see Metaprogramming Overview).
Source: Scala 3 Reference: Inline
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
inlineparameter is substituted at each use. The argumentexpensive()ran twice, once pers, and it did not run at all inlogged, because theinline ifon 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.
Source: Scala 3 Reference: Inline
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
Athat overridesplanfails withmethod plan of type (km: Double): String cannot override final member method plan in class A(errorE164). -
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.
-
inlineon a parameter is only allowed for parameters of inline methods.def h(inline y: Int) = yis rejected withinline 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 valfor 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 iforinline matchpluserrorto reject bad arguments (see Compile-Time Operations). -
Type-level computation:
transparent inlinewithinline matchanderasedValue. -
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 = twiceforinline def twice(x: Int): Intcompiles on 3.10.0 (it prints4forf(2)), butfis then an ordinary function value, and its calls are not expanded at the places wherefis called.
Comparison with other languages
-
Java has no inline modifier. The JIT compiler decides, at run time, which calls to inline, and a
static finalfield with a constant initializer is a compile-time constant thatjavaccopies into the class files that use it. That is the same mechanism asinline 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, withnoinlineandcrossinlineto opt parameters out of the rule, andreifiedtype parameters that make a type argument available in the body. Scala’sinlineparameters correspond to Kotlin’s lambda parameters of aninline fun, anderasedValueplays the role ofreified. Kotlin’sconst valcorresponds toinline val. Scala goes further becauseinline ifandinline matchevaluate at compile time. -
Scala 2 has
final valfor constants (still valid in Scala 3), the@inlineannotation (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
@inlineas the way to request inlining. In Scala 3,inlineis a modifier that always expands;@inlineis only a hint. -
final valconstants are described as the way to define compile-time constants. They still work, butinline valchecks 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:typerstill showstwice(a)for a call toinline def twice(inline x: Int) = x + x, and-Vprint:inliningshowsa + a. The phase list from-Vphaseshasinlining("inline and execute macros") afterpickler.