Scala 3 Module 2 – Basics: values, numbers, text and control flow
Methods, parameters and @main programs
Write Scala 3 methods with default, named, repeated and by-name parameters and several parameter lists, and a command-line program that exits 2 on bad input.
What you will learn
- Define methods with default, named and repeated parameters, and splice a sequence with xs*
- Use a by-name parameter to build a retry helper, and predict how often its argument runs
- Use several parameter lists, including a type parameter clause between them
- Build a command-line program that reports bad input with a non-zero exit status
Before you start
What you’ll learn · Before you start
What you will learn
- Define methods with default, named and repeated parameters, and splice a sequence with xs*
- Use a by-name parameter to build a retry helper, and predict how often its argument runs
- Use several parameter lists, including a type parameter clause between them
- Build a command-line program that reports bad input with a non-zero exit status
Before you start
On this page
retry(5)(flakyFetch()) in this lesson ran flakyFetch() three times, once for each attempt, although the call
names it once. That is a by-name parameter at work, one of five ways a Scala method can take its arguments: with
defaults, by name in the call, as many as you like, unevaluated, and in several parameter lists.
This lesson writes each of them on small fee and settings methods, then builds a command-line program on top: a tax
splitter whose @main method does nothing but hand its arguments to a plain run method and exit with the status it
returns, 2 for bad input, which a script or a CI job can test.
Default and named arguments make calls readable
A fee of 2 % with a minimum of 500 paise fits most payments, and a few need other numbers. Defaults keep the common call short, and names keep the rare one clear:
def fee(amount: Long, percent: Int = 2, minimum: Long = 500): Long =
math.max(amount * percent / 100, minimum)
def total(items: Long*): Long = items.sum
@main def fees(): Unit =
println(s"fee(100_000) = ${fee(100_000)}")
println(s"fee(10_000) = ${fee(10_000)}")
println(s"fee(100_000, minimum = 5_000) = ${fee(100_000, minimum = 5_000)}")
println(s"fee(percent = 3, amount = 100_000) = ${fee(percent = 3, amount = 100_000)}")
println(s"total() = ${total()}")
println(s"total(250, 400, 350) = ${total(250, 400, 350)}")
val basket = List(250L, 400L, 350L)
println(s"total(basket*) = ${total(basket*)}")
println(s"total(basket: _*) = ${total(basket: _*)}") from session import run
# Default and named arguments, a repeated parameter, and the old and the new way to pass a list to it.
run("""
cd params
scala run Fees.scala --server=false
""") Output
$ cd params
$ scala run Fees.scala --server=false
-- Warning: Fees.scala:15:54
15 | println(s"total(basket: _*) = ${total(basket: _*)}")
| ^
|The syntax `x: _*` is no longer supported for vararg splices; use `x*` instead
|This construct can be rewritten automatically under -rewrite -source 3.4-migration.
1 warning found
fee(100_000) = 2000
fee(10_000) = 500
fee(100_000, minimum = 5_000) = 5000
fee(percent = 3, amount = 100_000) = 3000
total() = 0
total(250, 400, 350) = 1000
total(basket*) = 1000
total(basket: _*) = 1000
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 params_session.py
The first four lines are calls of the same method. fee(100_000) used both defaults, 2 % and a minimum of 500, so the
fee is 2,000; on 10,000 paise 2 % is only 200, so the minimum, 500, applied. fee(100_000, minimum = 5_000) skipped
percent, which kept its default, and named the argument after it. fee(percent = 3, amount = 100_000) gave every
argument by name, in a different order from the parameters. The Scala 3 Book points out where names help most: when
several parameters have the same type, such as two Longs, a call with names cannot swap them by accident.
Name the arguments a reader cannot guess
fee(100_000, 3, 1_000) compiles, but a reader must look up which number is which. fee(100_000, percent = 3, minimum = 1_000) says it in the call, and it still works when a parameter is added later with a default.
Checkpoint
Check: Default and named arguments
A repeated parameter takes any number of arguments
total(items: Long*) has a repeated parameter: The last parameter of a list, declared with * after its type, such as items: Long*. It takes any number of arguments as a Seq, and xs* passes a whole sequence.: it takes zero, one or many
Longs, and inside the method items is a Seq[Long]. The last four
lines of the same output show it: total() is 0, total(250, 400, 350) is 1,000, and a list that is already built
goes in whole with basket*, which the Scala 3 Reference calls a vararg splice.
The older way to write the splice, basket: _*, still compiled and gave the same 1,000, but Scala 3.9 warned:
The syntax x: _* is no longer supported for vararg splices; use x* instead. The warning also names the compiler
option that rewrites old code for you, -rewrite -source 3.4-migration. A repeated parameter must be the last one of
its parameter list, which is why fee keeps its own parameters before any list of items.
Checkpoint
Check: Repeated parameters and splices
A by-name parameter runs each time it is used
A parameter declared op: => A is a by-name parameter: A parameter declared with an arrow before its type, such as op: => A. The caller's argument is not evaluated before the call; it runs each time the method uses the parameter.: the caller’s argument is
not evaluated before the call; the language specification says it is evaluated at each use within the method. That
is what a retry helper needs, because each attempt must run the operation again. Here flakyFetch fails twice and
then answers:
var attempts = 0
def flakyFetch(): String =
attempts += 1
if attempts < 3 then throw RuntimeException(s"timeout $attempts")
"200 OK"
def retry[A](times: Int)(op: => A): A =
try op
catch
case e: RuntimeException if times > 1 =>
println(s"failed: ${e.getMessage}, ${times - 1} left")
retry(times - 1)(op)
def twiceByName(msg: => String): Unit = { println(msg); println(msg) }
def twiceByValue(msg: String): Unit = { println(msg); println(msg) }
@main def retries(): Unit =
println(retry(5)(flakyFetch()))
println(s"attempts: $attempts")
var n = 0
twiceByName { n += 1; s"by name $n" }
twiceByValue { n += 1; s"by value $n" } from session import run
# A retry helper whose by-name parameter runs again on every attempt, and a by-name against a by-value parameter.
run("""
cd byname
scala run Retry.scala --server=false
""") Output
$ cd byname $ scala run Retry.scala --server=false failed: timeout 1, 4 left failed: timeout 2, 3 left 200 OK attempts: 3 by name 1 by name 2 by value 3 by value 3
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 byname_session.py
retry(5)(flakyFetch()) caught two timeouts, printed how many attempts were left, and returned 200 OK from the
third attempt; attempts is 3. Step through the three attempts:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- Nothing runs yet. Because op is declared => A, the caller passes the code flakyFetch() itself, not its result. Nothing has run when retry starts.
- Attempt 1. retry uses op inside its try, and only now does flakyFetch() run. It throws timeout 1.
- Catch and retry. The catch prints failed, timeout 1, 4 left, and retry calls itself with times = 4 and the same, still unrun, op.
- Attempt 2. Using op again runs flakyFetch() again. It throws timeout 2, and retry goes on with 3 left.
- Attempt 3. The third run of flakyFetch() returns 200 OK, so this try ends normally.
- The result. 200 OK goes back to the caller, and attempts is 3. The argument ran three times, once for each use of op.
- With a plain parameter. Had op been declared as op: A, the caller would run flakyFetch() once, before retry starts. Its exception would leave the call before retry could catch it.
The last four lines show the difference in one line each. twiceByName uses its by-name msg twice, so the block
{ n += 1; s"by name $n" } ran twice and printed by name 1 and by name 2. twiceByValue takes a plain String,
so the same kind of block ran once, before the call, and both lines say by value 3.
A by-name argument with a side effect
A by-name argument runs once for every use inside the method, so an argument such as { n += 1; … } or a payment
call can run more often than the call suggests. Keep by-name parameters for code that is meant to run again, such
as a retry, or not at all, such as a log message that is only built when logging is on.
Checkpoint
Check: By-name parameters
Several parameter lists, and type parameters between them
retry(times: Int)(op: => A) has two parameter lists. The second one holds the operation alone, so a caller can pass
it as a block in braces, retry(3) { … }, which reads like a built-in loop. A method can also be applied to its first
list only, which gives a function that waits for the rest. And Scala 3 allows a type parameter clause between two
parameter lists, which the Reference calls interleaving:
trait Codec[V]:
def decode(text: String): V
given Codec[Int] with
def decode(text: String) = text.toInt
given Codec[Boolean] with
def decode(text: String) = text.toBoolean
def setting[K](key: K)(settings: Map[K, String])[V](using codec: Codec[V]): V =
codec.decode(settings(key))
def within(limit: Long)(amount: Long): Boolean = amount <= limit
@main def settings(): Unit =
val conf = Map("port" -> "8080", "debug" -> "true")
val port = setting("port")(conf)[Int]
val debug = setting("debug")(conf)[Boolean]
println(s"port + 1 = ${port + 1}, debug = $debug")
val underCap = within(1_000)
println(List(250L, 1_200L, 1_000L).map(underCap)) from session import run
# Several parameter lists: a type parameter clause between them, and a method applied to its first list only.
run("""
cd lists
scala run Settings.scala --server=false
""") Output
$ cd lists $ scala run Settings.scala --server=false port + 1 = 8081, debug = true List(true, false, true)
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 lists_session.py
setting("port")(conf)[Int] reads the text "8080" and decodes it as an Int, so port + 1 is 8081, and
setting("debug")(conf)[Boolean] gives true. The key’s type, K, is inferred from "port", while the result type,
V, comes in its own clause after the settings, where the caller names it. The using clause then asks the compiler
for a Codec[V], a given instance: A value that the compiler passes by itself to a parameter declared with using, chosen by its type. sorted, for example, needs a given Ordering for the element type. More in “Read Scala compiler errors and warnings” chosen by that type; givens have a module of their
own later in this track. Without interleaving, the caller would have to name both types, setting[String, Int], in
front. within(1_000) is the first list applied alone: it gave a function from an amount to a Boolean, which map
used on three amounts, List(true, false, true).
Checkpoint
Check: Several parameter lists
A command-line program reports bad input with its exit status
The exit status: The number a program hands back to whatever started it when it ends: 0 for success, anything else for a failure. Shells and CI jobs decide by it. is how a program tells a script or a CI job whether it worked: 0 for
success, anything else for a failure. An @main method with typed parameters converts its arguments for you, but
the first programs of this track showed that on a bad argument it
prints Illegal command line and still ends with status 0. This program does the checking itself:
import scala.math.BigDecimal.RoundingMode
/** The whole program, without the JVM's exit: prints the split and returns the exit status. */
def run(args: Seq[String]): Int = args match
case Seq(total, rate) =>
(total.toLongOption, rate.toIntOption) match
case (Some(t), Some(r)) if t >= 0 && r >= 0 =>
val base = (BigDecimal(t) * 100 / (100 + r)).setScale(0, RoundingMode.HALF_UP).toLong
println(s"base $base, tax ${t - base} (paise)")
0
case _ =>
Console.err.println(s"error: expected whole numbers, got: ${args.mkString(" ")}")
2
case _ =>
Console.err.println("usage: taxsplit <total in paise> <rate in per cent>")
2
@main def taxsplit(args: String*): Unit = sys.exit(run(args)) @main def typed(total: Long, rate: Int): Unit =
println(s"total $total at $rate %") from session import run
# The typed @main program with a bad argument, then taxsplit with good, bad and missing arguments, and their exit
# statuses.
run("""
cd cli
scala run Typed.scala --server=false -- 11800 eighteen
scala run TaxSplit.scala --server=false -- 11800 18
!scala run TaxSplit.scala --server=false -- 11800 eighteen
!scala run TaxSplit.scala --server=false -- 11800
""") Output
$ cd cli $ scala run Typed.scala --server=false -- 11800 eighteen Illegal command line after first argument: java.lang.NumberFormatException: For input string: "eighteen" $ scala run TaxSplit.scala --server=false -- 11800 18 base 10000, tax 1800 (paise) $ scala run TaxSplit.scala --server=false -- 11800 eighteen error: expected whole numbers, got: 11800 eighteen [exit status 2] $ scala run TaxSplit.scala --server=false -- 11800 usage: taxsplit <total in paise> <rate in per cent> [exit status 2]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 cli_session.py
The typed version printed Illegal command line after first argument: java.lang.NumberFormatException for
eighteen, and the session shows no exit status after it: it was 0. taxsplit takes its arguments as String*, so
nothing can fail before its own code runs, and it hands them to run, which matches on their number and converts
them with toLongOption and toIntOption. Good input printed base 10000, tax 1800 (paise) and ended with status 0;
a word and a missing argument each printed a message to the error stream and ended with [exit status 2]. Step
through the run with the word:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The arguments. The shell starts the program with the two words after --. A repeated parameter, args: String*, receives them as text, so no conversion can fail here.
- Hand over to run. The @main method does nothing but call run with the arguments. All the logic is in run, a plain method that a test can call.
- Check the input. "11800".toLongOption is Some(11800), but "eighteen".toIntOption is None, so the case for two whole numbers does not apply.
- Report and return 2. run writes the problem to the error stream, error, expected whole numbers, and returns 2 instead of throwing.
- sys.exit(2). The main method passes the status to sys.exit, which ends the JVM with status 2.
- The caller can tell. The shell, or a CI job, sees status 2 and knows the run failed. The typed @main version printed Illegal command line and ended with status 0.
Keeping the work in run, a method that returns the status instead of calling sys.exit, has a second benefit: a
test can call run(Seq("11800", "eighteen")) and check that it returns 2, without starting a JVM for every case. For
programs with many options and flags, libraries such as mainargs parse them into typed parameters and print a help
text.
Key idea
Put a command-line program’s logic in a plain method that takes the arguments as text and returns an exit status,
and let the @main method do nothing but sys.exit(run(args)).
Checkpoint
Check: Exit statuses of a command-line program
Practise: a retry helper and a CLI wrapper
The program below checks a retry and a cli method. As given, retry tries its operation only once, and cli
returns 0 whatever the arguments are:
// Practise: make retry try op up to `times` times, and make cli return 0 for two
// whole numbers and 2 for anything else, as the checks below expect.
def retry[A](times: Int)(op: => A): A = op
def cli(args: Seq[String]): Int = 0
@main def checkPractice(): Unit =
var calls = 0
def flaky(failures: Int): String =
calls += 1
if calls <= failures then throw RuntimeException(s"failure $calls") else "done"
def attempt(times: Int, failures: Int): String =
calls = 0
val result = try retry(times)(flaky(failures)) catch case e: RuntimeException => e.getMessage
s"$result after $calls calls"
val checks = List(
"retry(3), first call works" -> (attempt(3, 0) == "done after 1 calls"),
"retry(3), two failures" -> (attempt(3, 2) == "done after 3 calls"),
"retry(2), always failing" -> (attempt(2, 9) == "failure 2 after 2 calls"),
"cli(11800, 18)" -> (cli(Seq("11800", "18")) == 0),
"cli(11800, eighteen)" -> (cli(Seq("11800", "eighteen")) == 2),
"cli()" -> (cli(Seq()) == 2),
"cli(11800, 18, 5)" -> (cli(Seq("11800", "18", "5")) == 2),
)
for (label, ok) <- checks do println(s"${if ok then "ok " else "FAIL"} $label")
println(s"${checks.count(_._2)} of ${checks.size} checks pass")
if checks.exists(!_._2) then sys.exit(1) from session import run
# The practice program as given: retry tries once, and cli always says 0.
run("""
cd practice
!scala run Practice.scala --server=false
""") Output
$ cd practice $ scala run Practice.scala --server=false ok retry(3), first call works FAIL retry(3), two failures FAIL retry(2), always failing ok cli(11800, 18) FAIL cli(11800, eighteen) FAIL cli() FAIL cli(11800, 18, 5) 2 of 7 checks pass [exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 practice_session.py
Two of the seven checks pass. Fill in both methods in a copy of practice/Practice.scala and run it again; the goal
is 7 of 7 checks pass. First hint: op is already by-name, so retry only has to
catch the exception and use op again while attempts are left; cli can match on Seq(a, b) and test
a.toLongOption and b.toIntOption, as run does.
Last, answer the questions on parameters, parameter lists and exit statuses, which explain each choice as you make it:
Practice that adapts to you
Practise this lesson
10 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · What is a by-name parameter in Scala, and when would you use one?
A by-name parameter is declared with an arrow before its type, as in op: => A. The argument the caller writes is not
evaluated before the call; it is evaluated each time the method uses the parameter, and not at all if the method never
uses it. A normal parameter is evaluated exactly once, before the method starts. By-name parameters suit code that
must run again or maybe not at all: a retry(times)(op) helper that runs op once per attempt, a timing helper that
measures how long a block takes, or a log method that builds its message only when logging is switched on. Two cautions
apply: an argument with a side effect runs once per use, and each use costs a call, so use the parameter once and keep
its value in a val when you need it twice.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Mid to senior
A retry helper took its operation as op: A and never retried. After a teammate changed it to op: => A, a payment call that timed out was retried and one customer was charged twice. Explain both bugs, and design a retry helper that a payments team can use safely.
-
Senior to staff
A library adds a parameter with a default, minimum: Long = 500, to a public method fee(amount, percent). Every call in the source still compiles, yet a service that was not recompiled now fails with NoSuchMethodError when it calls fee. Explain why, and how library authors add parameters without breaking such users.
Key takeaways
- Defaults keep common calls short, named arguments make the rest clear and may skip parameters or change their order, and both keep working when a parameter with a default is added.
- A repeated parameter,
items: Long*, takes any number of arguments as aSeq; pass a whole sequence withxs*, becausexs: _*now warns. - A by-name parameter,
op: => A, runs its argument at every use inside the method, which makes retry and logging helpers possible, and a plain parameter runs it once, before the call. - Several parameter lists let a caller pass a block in braces or apply the first list alone, and a type parameter clause between lists lets the caller name only the type that cannot be inferred.
- A command-line program keeps its logic in a
runmethod that returns an exit status, and its@mainmethod ends withsys.exit(run(args)), so bad input gives a non-zero status that scripts and tests can check.
Terms in this lesson
- by-name parameter
- A parameter declared with an arrow before its type, such as op: => A. The caller's argument is not evaluated before the call; it runs each time the method uses the parameter.
- exit status
- The number a program hands back to whatever started it when it ends: 0 for success, anything else for a failure. Shells and CI jobs decide by it.
- repeated parameter
- The last parameter of a list, declared with * after its type, such as items: Long*. It takes any number of arguments as a Seq, and xs* passes a whole sequence.
References
- Scala 3 Book: method features (Scala Center and EPFL (docs.scala-lang.org))
- Scala 3 Book: main methods (Scala Center and EPFL (docs.scala-lang.org))
- Scala 3 Reference: main methods (Scala Center and EPFL (docs.scala-lang.org))
- Scala 3 Reference: vararg splices (Scala Center and EPFL (docs.scala-lang.org))
- Scala 3 Reference: generalized method syntax (interleaved type parameter clauses) (Scala Center and EPFL (docs.scala-lang.org))
- SIP-47: clause interleaving (Scala Center and EPFL (docs.scala-lang.org))
- Scala Language Specification 3.4, chapter 4: basic declarations and definitions (by-name parameters) (The Scala Programming Language (scala-lang.org))
- Scala 3.9.0 API, scala.util.CommandLineParser (The Scala Programming Language (scala-lang.org))
- mainargs, command-line argument parsing for Scala (Li Haoyi (GitHub, com-lihaoyi/mainargs))
Report a problem with this lesson
Kept only in this browser. Your Learn progress