Scala Companion Objects
A companion object shares the same name as a class and lives in the same file. The class and its companion object have access to each other's private members. This pattern replaces Java's static methods cleanly — the companion object holds everything that belongs to the type as a whole, while the class holds everything that belongs to each instance.
Basic Companion Pattern
class Temperature(val celsius: Double):
def toFahrenheit: Double = celsius * 9 / 5 + 32
def toKelvin: Double = celsius + 273.15
override def toString: String = f"${celsius}%.1f°C"
object Temperature:
def fromFahrenheit(f: Double): Temperature = new Temperature((f - 32) * 5 / 9)
def fromKelvin(k: Double): Temperature = new Temperature(k - 273.15)
val absoluteZero: Temperature = new Temperature(-273.15)
val boilingPoint: Temperature = new Temperature(100.0)
// Use the companion to create instances
val body = Temperature.fromFahrenheit(98.6)
val boil = Temperature.boilingPoint
val freeze = Temperature.fromKelvin(273.15)
println(body) // 37.0°C
println(boil) // 100.0°C
println(freeze) // 0.0°C
println(body.toFahrenheit) // 98.6
Temperature (class) Temperature (companion object)
──────────────────────────── ─────────────────────────────────
celsius: Double fromFahrenheit(f): Temperature
toFahrenheit: Double fromKelvin(k): Temperature
toKelvin: Double absoluteZero: Temperature
toString: String boilingPoint: Temperature
Instance members Type-level members (like static)
apply — The Factory Method
Defining apply in the companion object lets callers create instances without the new keyword. This is how case classes work internally, and you can replicate it for regular classes:
class Circle private (val radius: Double):
val area: Double = Math.PI * radius * radius
val circumference: Double = 2 * Math.PI * radius
object Circle:
def apply(radius: Double): Circle =
require(radius > 0, "Radius must be positive")
new Circle(radius)
val unitCircle: Circle = Circle(1.0)
val c1 = Circle(5.0) // calls Circle.apply(5.0) — no 'new' needed
val c2 = Circle.unitCircle
println(f"Area: ${c1.area}%.2f") // Area: 78.54
// Circle(-1.0) // throws IllegalArgumentException — validation in apply
Private Constructor + Companion Factory
Making the constructor private forces all creation through the companion's factory, where you can validate or control instantiation:
class Email private (val address: String)
object Email:
def apply(address: String): Option[Email] =
if address.contains("@") && address.contains(".")
then Some(new Email(address))
else None
val valid = Email("user@example.com") // Some(Email)
val invalid = Email("not-an-email") // None
valid.foreach(e => println(s"Valid email: ${e.address}"))
invalid.foreach(e => println(e.address)) // nothing printed
Accessing Private Members Across Class/Companion
class BankAccount(private var balance: Double):
import BankAccount._ // access companion's private members
def transfer(amount: Double, other: BankAccount): Unit =
if amount <= balance then
debit(this, amount)
credit(other, amount)
object BankAccount:
private def debit(acc: BankAccount, amount: Double): Unit =
acc.balance -= amount // can access private field of class!
private def credit(acc: BankAccount, amount: Double): Unit =
acc.balance += amount
def apply(initialBalance: Double): BankAccount =
new BankAccount(initialBalance)
unapply — Enabling Pattern Matching
Define unapply in the companion to allow your class to participate in pattern matching (this is how case classes do it automatically):
class Point(val x: Int, val y: Int)
object Point:
def apply(x: Int, y: Int): Point = new Point(x, y)
def unapply(p: Point): Option[(Int, Int)] = Some((p.x, p.y))
val p = Point(3, 7)
p match
case Point(0, 0) => println("Origin")
case Point(x, 0) => println(s"On X-axis at $x")
case Point(0, y) => println(s"On Y-axis at $y")
case Point(x, y) => println(s"At ($x, $y)")
// At (3, 7)
Companion Object Rules
Rule 1: Same name as the class
Rule 2: Same file as the class
Rule 3: One companion object per class
Rule 4: Full access to each other's private members
Rule 5: Companion object IS a singleton (only one instance)
Complete Example: User System
class User private (
val id: Int,
val username: String,
val email: String,
private var loginCount: Int
):
def login(): Unit =
loginCount += 1
println(s"$username logged in (total: $loginCount)")
def stats: String = s"User $username: $loginCount logins"
object User:
private var nextId = 1
def apply(username: String, email: String): Option[User] =
if username.length >= 3 && email.contains("@") then
val user = new User(nextId, username, email, 0)
nextId += 1
Some(user)
else None
def guestUser: User = new User(0, "guest", "guest@temp.com", 0)
val alice = User("alice", "alice@example.com")
val bad = User("ab", "invalid") // None — username too short
alice.foreach { u =>
u.login()
u.login()
println(u.stats)
}
// alice logged in (total: 1)
// alice logged in (total: 2)
// User alice: 2 logins
val guest = User.guestUser
println(guest.username) // guest
