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

Leave a Comment

Your email address will not be published. Required fields are marked *