Scala 3 Module 3 – Domain modelling: classes, traits, case classes and enums
Classes, objects and companion objects
Write Scala 3 classes with parameters, defaults and access modifiers, build them without new, and use singleton and companion objects for factories.
What you will learn
- Define classes with constructor parameters, default values, auxiliary constructors and access modifiers
- Use singleton and companion objects for shared members, factories and private access
- Write a validating factory that returns Either, so that no invalid instance can be built
- Decide whether a class should be open, final or neither, and predict what the compiler reports
Before you start
What you’ll learn · Before you start
What you will learn
- Define classes with constructor parameters, default values, auxiliary constructors and access modifiers
- Use singleton and companion objects for shared members, factories and private access
- Write a validating factory that returns Either, so that no invalid instance can be built
- Decide whether a class should be open, final or neither, and predict what the compiler reports
Before you start
On this page
If anyone can write new Account("A1", -500), then sooner or later an account holds a bad id and a negative balance,
and every method that touches it has to check again. In this lesson’s runs, a private constructor and one factory
method in a companion object made Scala 3.9 refuse both ways around the check, Account("A-1", 0) and
new Account("A-2", 0), at compile time.
You will write classes with constructor parameters, defaults, auxiliary constructors and access modifiers, build them
without new, use singleton objects for shared members and companion objects for factories, and decide whether a
class should be open, final or neither. Every behaviour is shown by a program that the compiler built and ran.
A class bundles parameters, fields and a body
An invoice needs a customer, an amount in paise and a tax rate that is usually 18 %. This class takes them as constructor parameters, works out the tax in its body and offers the total as a method:
class Invoice(val customer: String, val paise: Long, taxPercent: Int = 18):
println(s"building the invoice for $customer")
val taxPaise: Long = paise * taxPercent / 100
def this(customer: String) = this(customer, 0)
def total: Long = paise + taxPaise
@main def invoices(): Unit =
val a = Invoice("Asha", 120_000)
println(s"${a.customer}: ${a.total} paise, ${a.taxPaise} of it tax")
val b = new Invoice("Ravi", 50_000, taxPercent = 5)
println(s"${b.customer}: ${b.total} paise")
val c = Invoice("Meera")
println(s"${c.customer}: ${c.total} paise") class Invoice(val customer: String, val paise: Long):
def this(customer: String) =
println("an invoice without an amount")
this(customer, 0) from session import run
# A class with val parameters, a plain parameter with a default, a body and an auxiliary constructor;
# the fields the JVM sees, listed by javap; and an auxiliary constructor that does not start with this(…).
run("""
cd invoice
scala run Invoice.scala --server=false
scala compile Invoice.scala --server=false -d out
javap -p -cp out Invoice
cd ../auxiliary
!scala compile AuxFirst.scala --server=false
""") Output
$ cd invoice
$ scala run Invoice.scala --server=false
building the invoice for Asha
Asha: 141600 paise, 21600 of it tax
building the invoice for Ravi
Ravi: 52500 paise
building the invoice for Meera
Meera: 0 paise
$ scala compile Invoice.scala --server=false -d out
$ javap -p -cp out Invoice
Compiled from "Invoice.scala"
public class Invoice {
private final java.lang.String customer;
private final long paise;
private final long taxPaise;
public Invoice(java.lang.String, long, int);
public java.lang.String customer();
public long paise();
public long taxPaise();
public Invoice(java.lang.String);
public long total();
public static int $lessinit$greater$default$3();
}
$ cd ../auxiliary
$ scala compile AuxFirst.scala --server=false
-- [E040] Syntax Error: AuxFirst.scala:3:4
3 | println("an invoice without an amount")
| ^^^^^^^
| 'this' expected, but identifier found
-- [E050] Type Error: AuxFirst.scala:4:4
4 | this(customer, 0)
| ^^^^
| class Invoice does not take parameters
|
| longer explanation available when compiling with `-explain`
2 errors found
Compilation failed
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 invoice_session.py
The parameter list after the class name is the primary constructor: The constructor made of a class's parameter list and its body. Every instance is built by it: val parameters become fields, and the statements of the body run from top to bottom.. A parameter
marked val becomes a field that anyone can read, like customer and paise; var would make it writable too. A
plain parameter such as taxPercent is only an input to the constructor. It has a default, 18, so the first invoice
left it out, and the second set it by name, taxPercent = 5.
The class body runs as part of the constructor, once for every instance: three invoices printed three lines that say
building the invoice for …. def this(customer: String) = this(customer, 0) is an
auxiliary constructor: An extra constructor written def this(…) in a class body. Its first statement must call another constructor of the class, so every instance still passes through the primary constructor.. Its first statement must call another constructor of
the class, so every instance still passes through the body. The last build of the session tries AuxFirst.scala, whose
auxiliary constructor prints first, and the compiler stops at the println: 'this' expected, but identifier found.
A default value usually does the same job with less code.
Only b was built with new. Scala 3 generates a universal apply method: An apply method that Scala 3 generates for each constructor of a concrete class, so Invoice("Asha", 120_000) builds an instance without new. None is generated when the companion already has a member named apply. for each
constructor of a class, so Invoice("Asha", 120_000) and Invoice("Meera") call those. Step through the first
invoice:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- A call without new. invoices() writes Invoice("Asha", 120_000) with no new. Scala 3 generated an apply method for each constructor of Invoice, so this call goes to apply.
- The default fills in. The call gives two arguments, so the third parameter, taxPercent, takes its default, 18. apply then calls the primary constructor with all three.
- The body runs. The class body is part of the constructor and runs from top to bottom, so its println prints building the invoice for Asha before anything else happens.
- A field is worked out. taxPaise = 120_000 * 18 / 100 is computed once, now, and kept as a field of this instance, 21,600 paise.
- The instance is ready. The constructor hands back the new Invoice. customer, paise and taxPaise are fields with accessors. taxPercent was only needed while building, so the class keeps no field for it.
- A method runs on each call. a.total is a def, so its body runs every time it is called, 120,000 + 21,600 = 141,600 paise, as the program printed.
The javap listing at the end of the session shows what the compiler kept: fields for customer, paise and
taxPaise, each with an accessor method, and nothing for taxPercent, because only the body used it. The default 18
became a small static method that a call without the argument runs.
Checkpoint
Check: Constructor parameters, fields and the class body
Access modifiers decide who can read a member
A wallet keeps its balance to itself and offers an audit line only to its subclasses. This program tries to read three members from outside the class, and the compiler refuses all three:
class Wallet(val owner: String, private var balancePaise: Long, limitPaise: Long = 1_000_000):
def add(paise: Long): Unit =
if balancePaise + paise <= limitPaise then balancePaise += paise
protected def audit: String = s"$owner holds $balancePaise paise"
class FamilyWallet(owner: String, start: Long) extends Wallet(owner, start):
def report: String = "family wallet: " + audit
@main def access(): Unit =
val w = Wallet("Asha", 500)
println(w.owner)
println(w.limitPaise)
println(w.balancePaise)
println(w.audit)
println(FamilyWallet("Ravi", 700).report) from session import run
# Reading a plain parameter, a private field and a protected method from outside the class.
run("""
cd access
!scala compile Access.scala --server=false
""") Output
$ cd access $ scala compile Access.scala --server=false -- [E173] Reference Error: Access.scala:12:12 12 | println(w.limitPaise) | ^^^^^^^^^^^^ |value limitPaise cannot be accessed as a member of (w : Wallet) from the top-level definitions in package <empty>. | private value limitPaise can only be accessed from class Wallet. -- [E173] Reference Error: Access.scala:13:12 13 | println(w.balancePaise) | ^^^^^^^^^^^^^^ |variable balancePaise cannot be accessed as a member of (w : Wallet) from the top-level definitions in package <empty>. | private variable balancePaise can only be accessed from class Wallet. -- [E173] Reference Error: Access.scala:14:12 14 | println(w.audit) | ^^^^^^^ |method audit cannot be accessed as a member of (w : Wallet) from the top-level definitions in package <empty>. | protected method audit can only be accessed from class Wallet or one of its subclasses. 3 errors found Compilation failed [exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 access_session.py
The errors name the three rules. limitPaise is a plain parameter that the method add uses, so the compiler keeps
it as a private field: private value limitPaise can only be accessed from class Wallet. balancePaise is declared
private var. audit is protected, which the message spells out: it can only be accessed from class Wallet or one
of its subclasses, which is why FamilyWallet.report drew no error.
| Modifier | Who may read the member |
|---|---|
| none (public) | All code |
protected |
The class, its companion and its subclasses, but not the rest of the package |
private[billing] |
All code inside package billing and its subpackages; nothing outside, not even a subclass |
private |
The class and its companion only |
The table holds two surprises for Java programmers. protected does not open a member to the rest of its package, as
it does in Java. And a companion object, which comes two sections on, sees the private members of its class.
private[billing] is the qualified private: private[pkg] on a member: it is visible everywhere inside package pkg and its subpackages, and nowhere else. It sits between private and public. More in “Packages, imports, exports and top-level code” of the lesson on packages.
Writing private[this] in new code
Older Scala code marks fields private[this]. The Scala 3 Reference deprecates private[this] and
protected[this]: write private, and the compiler itself works out which private members are only used through
this.
Checkpoint
Check: private, protected and qualified access
An object is one instance, set up on first use
Some values belong to no single invoice: the tax rates themselves. An object: A singleton: a class with exactly one instance, created when it is first used. The members of an object play the role of Java's static members. More in “Scala 3 for Java and Python developers” holds
them. It is a class with exactly one instance, which the program never creates with new:
object TaxRates:
println("TaxRates: setting up the rates")
val standard: Int = 18
val reduced: Int = 5
@main def rates(): Unit =
println("main: started")
println(s"standard rate: ${TaxRates.standard} %")
println(s"reduced rate: ${TaxRates.reduced} %") from session import run
# When an object's body runs: at the first use of the object, and only once.
run("""
cd rates
scala run Rates.scala --server=false
""") Output
$ cd rates $ scala run Rates.scala --server=false main: started TaxRates: setting up the rates standard rate: 18 % reduced rate: 5 %
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 rates_session.py
main: started came first, although TaxRates is defined above rates(). Its body ran only when rates() first read
TaxRates.standard, and it ran once: the second read, TaxRates.reduced, printed no second set-up line. The Scala 3
Book describes objects this way, set up lazily when their members are first used, as a lazy val is. Step through the
four lines:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- main starts. rates() prints "main: started". Nothing has used TaxRates yet, so its body has not run and its vals hold nothing.
- The first use. The next line reads TaxRates.standard. This is the first use of the object, so the JVM sets it up now.
- The body runs once. Setting up runs the object's body from top to bottom, like a class body. Its println prints, and standard and reduced get their values, 18 and 5.
- The value comes back. standard is 18, so main prints standard rate 18 %. The object now exists for the rest of the run.
- A second use. TaxRates.reduced uses the same single instance. Its body does not run again, which is why setting up the rates was printed only once.
An object can hold what a class body holds, such as vals and defs, and code reaches them as TaxRates.standard or
imports them. That makes objects the place for what Java writes as static members.
Key idea
A class body runs once for every instance you create; an object’s body runs once in the whole program, on the first use of the object.
Checkpoint
Check: Objects set up once, on first use
A companion object shares private members with its class
An object with the same name as a class, in the same file, is its companion object: An object with the same name as a class, defined in the same file. The two can read each other's private members, and the object holds what belongs to the type rather than to one instance, such as factory methods..
The Scala 3 Book states the rule that makes the pair useful: a companion class or object can access the private
members of its companion. Account.scala uses it in both directions:
class Account private (val id: String, private val paise: Long):
def deposit(amount: Long): Either[String, Account] =
if amount < Account.MinDepositPaise then Left(s"a deposit must be at least ${Account.MinDepositPaise} paise, not $amount")
else Right(new Account(id, paise + amount))
override def toString = s"Account($id, $paise paise)"
object Account:
private val MinDepositPaise = 100
private val IdFormat = "A-[0-9]{1,6}".r
private var opened = 0
def create(id: String): Either[String, Account] =
if !IdFormat.matches(id) then Left(s"bad account id: $id")
else
opened += 1
Right(new Account(id, 0))
def richer(a: Account, b: Account): Account = if a.paise >= b.paise then a else b
def openedSoFar: Int = opened @main def accounts(): Unit =
println(Account.create("A1"))
Account.create("A-1") match
case Left(reason) => println(reason)
case Right(empty) =>
println(empty)
println(empty.deposit(5))
val topped = empty.deposit(50_000)
println(topped)
topped match
case Right(full) => println(s"richer: ${Account.richer(empty, full)}")
case Left(reason) => println(reason)
println(s"still: $empty")
println(s"accounts opened: ${Account.openedSoFar}") from session import run
# A private constructor, a companion with a validating factory, and private members shared both ways;
# then the class as the JVM sees it.
run("""
cd accounts
scala run . --server=false
scala compile Account.scala --server=false -d out
javap -p -cp out Account
""") Output
$ cd accounts
$ scala run . --server=false
Left(bad account id: A1)
Account(A-1, 0 paise)
Left(a deposit must be at least 100 paise, not 5)
Right(Account(A-1, 50000 paise))
richer: Account(A-1, 50000 paise)
still: Account(A-1, 0 paise)
accounts opened: 1
$ scala compile Account.scala --server=false -d out
$ javap -p -cp out Account
Compiled from "Account.scala"
public class Account {
private final java.lang.String id;
private final long paise;
public Account(java.lang.String, long);
public java.lang.String id();
public long Account$$paise();
public scala.util.Either<java.lang.String, Account> deposit(long);
public java.lang.String toString();
public static scala.util.Either<java.lang.String, Account> create(java.lang.String);
public static int openedSoFar();
public static Account richer(Account, Account);
}
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 accounts_session.py
The class reads its companion’s private MinDepositPaise to refuse the deposit of 5 paise. The companion reads the
private field paise of two accounts in richer, and it keeps a private counter, opened, that no single account
owns: one account was opened, because the id A1 was refused. Step through who reads what:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- One name, one file. Account.scala defines class Account and object Account. Because they share a name and a file, each is the companion of the other.
- Through the factory. accounts() cannot call the constructor, which is private. It calls Account.create("A-1"), a public method of the companion.
- The companion builds it. create checks the id against its private IdFormat, adds 1 to its private counter opened and calls new Account. The private constructor is open to the companion.
- The class reads the companion. deposit(5) compares the amount with Account.MinDepositPaise, a private val of the companion. The class may read it, and refuses the deposit of 5.
- The companion reads the class. richer(empty, full) compares a.paise with b.paise. paise is private to the class, yet the companion reads it from both accounts.
- Everyone else. accounts() uses only public members, create, richer and openedSoFar of the companion, deposit and toString of the class. paise, IdFormat and the constructor are out of its reach.
A companion is also where a class’s own apply methods go. Scala 3 generates the universal apply methods of the
first section only while the companion has no member named apply, so writing one replaces them all. Account
defines create instead, a name that says the call can be refused.
Checkpoint
Check: Companion objects and shared private members
A private constructor makes the factory the only way in
deposit did not change the empty account: still: Account(A-1, 0 paise) came after the deposit of 50,000. Each
deposit returns a new Account inside an Either. The API documentation states the convention: Left is used for
failure and Right for success, so the type of create, Either[String, Account], tells every caller that the call
can fail and why.
The check in create only protects the class if nobody can go around it. This program tries both ways:
class Account private (val id: String, private val paise: Long)
object Account:
def create(id: String): Either[String, Account] = Right(new Account(id, 0))
@main def bypass(): Unit =
val a = Account("A-1", 0)
val b = new Account("A-2", 0) from session import run
# Trying to go around the factory: the generated apply and new both meet the private constructor.
run("""
cd bypass
!scala compile Bypass.scala --server=false
""") Output
$ cd bypass
$ scala compile Bypass.scala --server=false
-- [E050] Type Error: Bypass.scala:7:10
7 | val a = Account("A-1", 0)
| ^^^^^^^
| object Account does not take parameters
|
| longer explanation available when compiling with `-explain`
-- [E173] Reference Error: Bypass.scala:8:14
8 | val b = new Account("A-2", 0)
| ^^^^^^^
|constructor Account cannot be accessed as a member of Account from the top-level definitions in package <empty>.
| private constructor Account can only be accessed from class Account.
2 errors found
Compilation failed
[exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 bypass_session.py
The word private before the parameter list makes the constructor private. new Account("A-2", 0) failed with E173:
the private constructor can only be accessed from class Account, and from its companion, as create shows.
Account("A-1", 0) found no apply it could use, so the compiler said object Account does not take parameters.
Every Account that Scala code builds has therefore passed the check. Step through the two calls of create:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- create("A1"). main calls create("A1"). The companion checks the id against the pattern A- followed by 1 to 6 digits.
- Refused. A1 has no hyphen, so create returns Left("bad account id: A1") and never calls the constructor. No Account with a bad id exists, even for a moment.
- create("A-1"). The next call passes A-1, which matches, so create goes on down the column.
- Counted and built. create adds 1 to opened and calls the private constructor with a balance of 0. Only code inside the class and its companion can make this call.
- Accepted. create returns Right(Account(A-1, 0 paise)). The type Either[String, Account] tells every caller that the call can fail, and with which reason.
A public constructor and a separate validate method
class Account(val id: String) with def validate(a: Account): Boolean in the companion lets every caller build an
account first and forget to validate it. Make the constructor private and let the factory validate, so a bad
Account cannot exist.
Returning Either is a choice for errors a caller should handle, such as a bad id typed by a user. For a mistake in
the program itself, a constructor can call require(paise >= 0), which throws an IllegalArgumentException.
Scala's private, as the JVM sees it
The javap listing at the end of the accounts session shows the private constructor as
public Account(java.lang.String, long), because the companion is a separate class on the JVM, and create as a
public static method that forwards to the companion. The Scala compiler enforces the private constructor; Java code
could still call it, so a class that Java code uses checks its rules in the constructor as well.
Checkpoint
Check: A private constructor with a validating factory
open, final or neither decides who may extend a class
Ledger below has no modifier and Report is declared open; both are extended in another file. The session builds
the pair three times: as a program, with -feature, and with -source future -feature:
class Ledger:
def record(entry: String): String = s"recorded $entry"
open class Report:
def title: String = "Report" class AuditedLedger extends Ledger:
override def record(entry: String): String = "audited, " + super.record(entry)
class MonthlyReport extends Report:
override def title: String = "Monthly report"
@main def extend(): Unit =
println(AuditedLedger().record("rent"))
println(MonthlyReport().title) from session import run
# Ledger is not open and Report is; both are extended in another file. Three builds with different options.
run("""
cd extension
scala run . --server=false
scala compile . --server=false -feature
scala compile . --server=false -source future -feature
""") Output
$ cd extension $ scala run . --server=false audited, recorded rent Monthly report $ scala compile . --server=false -feature $ scala compile . --server=false -source future -feature -- Feature Warning: Extend.scala:1:28 1 |class AuditedLedger extends Ledger: | ^^^^^^ |Unless class Ledger is declared 'open', its extension in a separate file should be enabled |by adding the import clause 'import scala.language.adhocExtensions' |or by setting the compiler option -language:adhocExtensions. |See the Scala docs for value scala.language.adhocExtensions for a discussion |why the feature should be explicitly enabled. 1 warning found
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 extension_session.py
The first two builds were silent. Only -source future, which turns on the rules planned for later versions, gave a
feature warning, and only for Ledger: its extension in a separate file should be enabled with
import scala.language.adhocExtensions. The Reference calls this an ad hoc extension. It is allowed, because it is
useful for test fakes and quick fixes, but a class without open was not designed for it, and its author may change
what a subclass depends on.
Version note
The Reference says that the warning for ad hoc extensions will be produced by default from Scala 3.4 on. On Scala
3.9.0 it still appeared only with -source future, as the session shows, so check your own builds with that option.
final closes a class completely, in its own file too:
final class Statement(val lines: List[String])
class LongStatement extends Statement(Nil) from session import run
# A final class cannot be extended, in this file or any other.
run("""
cd closed
!scala compile Statement.scala --server=false
""") Output
$ cd closed $ scala compile Statement.scala --server=false -- [E093] Syntax Error: Statement.scala:3:6 3 |class LongStatement extends Statement(Nil) | ^ | class LongStatement cannot extend final class Statement | | longer explanation available when compiling with `-explain` 1 error found Compilation failed [exit status 1]
Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 closed_session.py
E093 reads class LongStatement cannot extend final class Statement. Traits and abstract classes are open by nature,
so open is only written on concrete classes. Answer the questions for a class of your own:
Answer the questions above: the table then marks what fits your case and says why the rest does not.
| Declaration | Subclass in its file | Subclass in another file | Changing it later | A fake for tests |
|---|---|---|---|---|
| final class Who may extend it: no one | Poor refused there too: E093, cannot extend final class | Poor refused with E093 | Good no subclass can depend on how it works inside | Poor no subclass possible; test it through its public methods |
| class, no modifier Who may extend it: its own file; other files ad hoc | Good allowed, with no warning | Mixed compiled; a feature warning only under -source future | Mixed subclasses elsewhere extend it at their own risk | Mixed a fake may extend it, with import scala.language.adhocExtensions |
| open class Who may extend it: any file, by design | Good allowed, with no warning | Good allowed anywhere, with no warning | Poor every method a subclass may override is a promise to keep | Good any test may extend it |
How the answers decide
- “Who should be able to write a subclass of it?” No one, ever: rules out class, no modifier and open class. Only final refuses every subclass. A plain class can still be extended in its own file, and in other files with a warning at most.
- “Who should be able to write a subclass of it?” Only code in the same file: rules out final class. final refuses subclasses in its own file as well, so the variants you plan could not be written.
- “Who should be able to write a subclass of it?” Only code in the same file: adds a caveat to open class. open invites subclasses from every file, a promise to all users that this class does not need to make.
- “Who should be able to write a subclass of it?” Anyone: it is designed for it: rules out final class. A final class cannot be extended at all: the compiler stops every subclass with E093.
- “Who should be able to write a subclass of it?” Anyone: it is designed for it: adds a caveat to class, no modifier. Without open, every subclass in another file is an ad hoc extension: it compiles, but -source future warns about it and asks for adhocExtensions.
- “Will tests need a fake version of it that overrides some methods?” Yes: adds a caveat to final class. No fake can extend a final class. Put the methods tests replace behind a trait, or test the class through its public methods.
In short: final for a class nobody may change by subclassing, open for a class designed with methods to override,
and no modifier while you have not decided. A sealed class, which allows subclasses only in its own file, comes with
enums later in this module.
Checkpoint
Check: open, final and ad hoc extension
Practise: an account that refuses bad input
The program below checks a BankAccount with a private constructor. As given, open accepts every id, and deposit
and withdraw return the same account whatever the amount:
// Practise: open accepts ids such as "B-42" and refuses anything else; deposit and
// withdraw return a new account, or the reason they refuse, and never change this one.
final class BankAccount private (val id: String, val balancePaise: Long):
def deposit(paise: Long): Either[String, BankAccount] = Right(this)
def withdraw(paise: Long): Either[String, BankAccount] = Right(this)
override def toString = s"BankAccount($id, $balancePaise paise)"
object BankAccount:
def open(id: String): Either[String, BankAccount] = Right(new BankAccount(id, 0))
@main def checkPractice(): Unit =
val start = BankAccount.open("B-42") match
case Right(account) => account
case Left(reason) => sys.error(s"open refused B-42: $reason")
def balance(result: Either[String, BankAccount]): Long = result match
case Right(account) => account.balancePaise
case Left(_) => -1
val after = start.deposit(10_000)
val checks = List(
"open(B-42) starts at 0 paise" -> (start.balancePaise == 0),
"open(42) is refused" -> BankAccount.open("42").isLeft,
"deposit(10000) gives 10000" -> (balance(after) == 10_000),
"deposit(-500) is refused" -> start.deposit(-500).isLeft,
"withdraw(2500) after it gives 7500" -> (balance(after.flatMap(_.withdraw(2_500))) == 7_500),
"withdraw(1) from 0 is refused" -> start.withdraw(1).isLeft,
"withdraw(0) is refused" -> start.withdraw(0).isLeft,
"start still holds 0 paise" -> (start.balancePaise == 0),
)
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: open accepts any id, and deposit and withdraw change nothing.
run("""
cd practice
!scala run BankAccount.scala --server=false
""") Output
$ cd practice $ scala run BankAccount.scala --server=false ok open(B-42) starts at 0 paise FAIL open(42) is refused FAIL deposit(10000) gives 10000 FAIL deposit(-500) is refused FAIL withdraw(2500) after it gives 7500 FAIL withdraw(1) from 0 is refused FAIL withdraw(0) is refused ok start still holds 0 paise 2 of 8 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 eight checks pass. Write open, deposit and withdraw in your own copy of practice/BankAccount.scala
until scala run BankAccount.scala prints 8 of 8 checks pass. First hint: open answers Left for an id that is
not B- followed by digits; deposit and withdraw answer Left for an amount of 0 or less, withdraw also for more
than balancePaise, and otherwise return Right(new BankAccount(id, …)) with the new balance, as Account.deposit
does.
Then answer the questions on classes, objects and companions, each explained as you answer it:
Practice that adapts to you
Practise this lesson
16 questions about 6 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 companion object in Scala, and what is it used for?
A companion object is an object with the same name as a class, defined in the same file. The class and its companion
can read each other’s private members, which a separate object could not. The companion holds what belongs to the type
rather than to one instance: constants, counters, helpers and, above all, factory methods, such as an apply or a
create that checks its arguments and returns an Either or an Option. With the constructor made private, that
factory becomes the only way for Scala code to build an instance, so no instance skips the checks. Scala 3 also puts
the generated apply methods in the companion, which is why Invoice(…) works without new, unless the companion
defines an apply itself. Java code reaches the companion’s methods through static forwarders on the class.
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to senior
A payments service has a Money class with a public constructor and a validate method that callers are meant to run first. Invalid amounts still reach the database every few weeks. How would you redesign the class so that an invalid Money cannot exist, and what would you check before rolling the change out?
-
Mid to senior
Your team publishes a library with a plain class RateCalculator. A client team extended it in their own code and overrode one method. After an internal refactor of yours, their subclass gives wrong totals, with no compile error anywhere. What went wrong, and how would you declare your library's classes from now on?
Key takeaways
- A class’s parameter list is its primary constructor:
valparameters become public fields, plain ones stay private to the class, defaults replace most auxiliary constructors, and the class body runs for every instance. - Scala 3 generates an
applymethod for each constructor, soInvoice("Asha", 120_000)needs nonew, unless the companion defines anapplyof its own. privatemembers are visible to the class and its companion,protectedones to subclasses as well, but not to the rest of the package as in Java.- An object is a single instance whose body runs once, on its first use; a companion object shares private members with its class and holds its factories.
- A private constructor and a factory that returns
Eithermake invalid instances impossible to build. - Declare a class
finalwhen nobody may extend it andopenwhen it is designed for extension; on Scala 3.9.0, extending a plain class from another file was warned about only under-source future.
Terms in this lesson
- auxiliary constructor
- An extra constructor written def this(…) in a class body. Its first statement must call another constructor of the class, so every instance still passes through the primary constructor.
- companion object
- An object with the same name as a class, defined in the same file. The two can read each other's private members, and the object holds what belongs to the type rather than to one instance, such as factory methods.
- primary constructor
- The constructor made of a class's parameter list and its body. Every instance is built by it: val parameters become fields, and the statements of the body run from top to bottom.
- universal apply method
- An apply method that Scala 3 generates for each constructor of a concrete class, so Invoice("Asha", 120_000) builds an instance without new. None is generated when the companion already has a member named apply.
References
- Scala 3 Book: domain modelling tools (Scala Center and EPFL (docs.scala-lang.org))
- Scala 3 Book: object-oriented modelling (Scala Center and EPFL (docs.scala-lang.org))
- Scala 3 Book: singleton objects (Scala Center and EPFL (docs.scala-lang.org))
- Scala 3 Reference: universal apply methods (Scala Center and EPFL (docs.scala-lang.org))
- Scala 3 Reference: open classes (Scala Center and EPFL (docs.scala-lang.org))
- Scala 3 Reference: dropped this qualifiers (Scala Center and EPFL (docs.scala-lang.org))
- Scala 3 Reference, error code E093 (extend final class) (Scala Center and EPFL (docs.scala-lang.org))
- Scala 3.9.0 API, scala.util.Either (The Scala Programming Language (scala-lang.org))
Report a problem with this lesson
Kept only in this browser. Your Learn progress