Skip to Content
Scalus Club is now open! Join us to get an early access to new features 🎉

Scala Metaprogramming

Scala 3 inline keyword is a powerful tool for on-chain code optimization. The Scala compiler evaluates inline expressions at compile time, before the Scalus compiler plugin sees the code. This lets you control exactly what gets compiled to UPLC.

Inlining

The inline keyword tells the Scala compiler to substitute the expression at its usage site. This eliminates a let binding in UPLC, saving CPU on the lambda application. But inlining is a trade-off — it can help or hurt depending on how many times the expression is used.

When inlining helps: single use site

In a recursive hash chain, the combiner function has one call site per recursion step. Inlining eliminates the lambda overhead without duplicating code:

import scalus.*, scalus.compiler.compile import scalus.uplc.builtin.Builtins.* import scalus.uplc.builtin.{BuiltinList, ByteString, Data} @Compile object Checksum { // inline: body substituted at the single call site in checksum inline def combine(acc: ByteString, elem: ByteString): ByteString = blake2b_256(appendByteString(acc, elem)) def checksum(list: BuiltinList[Data], acc: ByteString): ByteString = if list.isEmpty then acc else checksum(list.tail, combine(acc, list.head.toByteString)) }

Here combine appears once in the recursive body — inline saves CPU with no size penalty. The optimizer may also inline single-use functions automatically, but marking them inline guarantees it.

When inlining hurts

If the same function is called from many places, inline duplicates the body at every call site. The script gets bigger:

import scalus.*, scalus.compiler.compile import scalus.uplc.builtin.Builtins.* import scalus.uplc.builtin.{ByteString, Data} @Compile object FieldValidator { // inline: body duplicated 3 times → bigger script inline def checkNonEmpty(bs: ByteString): Unit = lengthOfByteString(bs) > BigInt(0) || (throw new RuntimeException("empty")) def validate(datum: Data, redeemer: Data, ctx: Data): Unit = { val fields = datum.toConstr.snd checkNonEmpty(fields.head.toByteString) // body copied here checkNonEmpty(fields.tail.head.toByteString) // body copied here checkNonEmpty(fields.tail.tail.head.toByteString) // body copied here } }

Compare with a regular def (single lambda, called 3 times):

inline defdef
Flat size126 B103 B
CPU steps2,815,8623,007,862
Memory10,88812,088
Execution fee832 lovelace915 lovelace
Script size fee (44 lovelace/byte)5,544 lovelace4,532 lovelace
Total transaction fee6,376 lovelace5,447 lovelace

inline wins on execution (-7% CPU) but the 23 extra bytes of script size cost 1,012 lovelace more. The total transaction fee is 17% higher with inline — the size component dominates.

Execution fee is only part of the transaction fee. Cardano charges 44 lovelace per byte of transaction size (txFeePerByte), and the flat-encoded script is part of the transaction. Inlining that saves a few thousand CPU steps but adds bytes to the script can easily cost more overall. Always measure the full transaction fee, not just execution.

Loop Unrolling

When the number of iterations is known at compile time, you can use inline to unroll a loop. The Scala compiler expands the recursive inline calls, producing straight-line code with no lambda overhead per iteration:

import scalus.*, scalus.compiler.compile import scalus.uplc.builtin.Builtins.* import scalus.uplc.builtin.{BuiltinList, Data} @Compile object Unrolled { // The Scala compiler unrolls this at compile time: // checkN(3, sigs, expected) becomes: // sigs.head == expected.head; checkN(2, sigs.tail, expected.tail) // sigs.head == expected.head; checkN(1, sigs.tail, expected.tail) // sigs.head == expected.head; checkN(0, sigs.tail, expected.tail) // () inline def checkN(inline n: Int, sigs: BuiltinList[Data], expected: BuiltinList[Data]): Unit = inline if n <= 0 then () else { sigs.head == expected.head || (throw new RuntimeException("missing sig")) checkN(n - 1, sigs.tail, expected.tail) } def validator(datum: Data, redeemer: Data, ctx: Data): Unit = { val sigs = ctx.toConstr.snd.head.toList val expected = datum.toList checkN(3, sigs, expected) // unrolled to 3 direct checks at compile time } }

The Scala compiler resolves inline if n <= 0 at compile time, recursively expanding checkN until n reaches 0. The Scalus compiler plugin only sees the final straight-line code — no recursion, no lambda calls per iteration.

UnrolledRecursive
Flat size137 B116 B
CPU steps6,212,6208,062,548
Fee1,144 lovelace1,735 lovelace

Unrolling saves 23% CPU and 34% fee for 3 iterations, at the cost of 21 extra bytes of script size.

Inlining Constants

When you mark a parameter as inline, its value is directly embedded in the compiled code. This is particularly useful for configuration like public key hashes:

import scalus.*, scalus.compiler.*, uplc.builtin.{Data, Builtins, ByteString}, Builtins.*, ByteString.* inline def validator(inline pubKeyHash: ByteString)(datum: Data, redeemer: Data, ctxData: Data) = verifyEd25519Signature(pubKeyHash, datum.toByteString, redeemer.toByteString) val script = compile: validator(hex"deadbeef")

This generates SIR with the constant #deadbeef directly embedded — no runtime parameter passing:

{λ datum redeemer ctxData -> verifyEd25519Signature(#deadbeef, unBData(datum), unBData(redeemer)) }

Compile-Time Evaluation

When a closed function (no free variables) is applied to constants inside compile, the entire computation is evaluated at compile time by the optimizer’s partial evaluator:

import scalus.*, scalus.compiler.* import scala.annotation.tailrec @Compile object Fibonacci { def fib(n: BigInt): BigInt = @tailrec def f(n: BigInt, x: BigInt, y: BigInt): BigInt = if n > 1 then f(n - 1, y, x + y) else y f(n, 0, 1) } // fib(100) is fully evaluated at compile time! val sir = compile(Fibonacci.fib(100)) val uplc = sir.toUplcOptimized() // uplc is now just: (Const Integer 354224848179261915075)

The recursive loop runs during compilation. The final script contains only the constant. This works for any closed function applied to constant arguments — precomputed lookup tables, derived configuration, hash preimages, mathematical constants. Constant folding is also implemented in the UPLC Optimiser Pipeline, which can fold closed subexpressions at the UPLC level.

Conditional Code Generation

Using inline if with a compile-time parameter, you can generate different code at compile time. This is useful for creating separate debug and production versions of your validators:

import scalus.*, scalus.compiler.*, uplc.builtin.Data, uplc.builtin.Builtins inline def dbg[A](msg: String)(a: A)(using debug: Boolean): A = inline if debug then Builtins.trace(msg)(a) else a inline def validator(using debug: Boolean)(datum: Data, redeemer: Data, ctxData: Data) = dbg("datum")(datum) val releaseScript = compile(validator(using false)) // {λ datum redeemer ctxData -> datum } val debugScript = compile(validator(using true)) // {λ datum redeemer ctxData -> trace("datum", datum) }

The releaseScript contains no trace calls at all — the conditional code is evaluated during Scala compilation, so there’s zero runtime overhead for disabled features.

What’s Next?

  • Low-Level Builtins — once the high-level structure is tight, drop to UPLC builtins and raw Plutus Data for further savings.
  • UPLC Optimiser Pipeline — see exactly which optimiser passes pick up the patterns produced by inline.
Last updated on