Scala - Companion Objects

• Companion objects enable static-like functionality in Scala while maintaining full object-oriented principles, providing a cleaner alternative to Java's static members through shared namespace with...

Key Insights

• Companion objects enable static-like functionality in Scala while maintaining full object-oriented principles, providing a cleaner alternative to Java’s static members through shared namespace with their companion class. • Factory methods in companion objects leverage private constructors for validation and smart constructors, eliminating the need for new keyword and enabling flexible object creation patterns. • The apply/unapply mechanism in companion objects powers Scala’s pattern matching and enables elegant DSL construction, making code more idiomatic and expressive.

Understanding Companion Objects

A companion object is an object that shares the same name as a class and is defined in the same source file. Unlike Java’s static members, Scala companion objects are true singleton objects with full access to their companion class’s private members, and vice versa.

class BankAccount(private val accountNumber: String, private var balance: Double) {
  
  def deposit(amount: Double): Unit = {
    require(amount > 0, "Deposit amount must be positive")
    balance += amount
  }
  
  def withdraw(amount: Double): Boolean = {
    if (balance >= amount) {
      balance -= amount
      true
    } else false
  }
  
  def getBalance: Double = balance
}

object BankAccount {
  // Companion object can access private members of the class
  def transfer(from: BankAccount, to: BankAccount, amount: Double): Boolean = {
    if (from.balance >= amount) {
      from.balance -= amount
      to.balance += amount
      true
    } else false
  }
  
  // Factory method
  def apply(accountNumber: String, initialBalance: Double): BankAccount = {
    require(initialBalance >= 0, "Initial balance cannot be negative")
    new BankAccount(accountNumber, initialBalance)
  }
}

// Usage
val account1 = BankAccount("ACC001", 1000.0)
val account2 = BankAccount("ACC002", 500.0)
BankAccount.transfer(account1, account2, 200.0)

Factory Methods and Smart Constructors

Companion objects excel at providing factory methods that encapsulate object creation logic. This pattern allows validation, caching, and returning subtypes without exposing implementation details.

sealed trait DatabaseConnection {
  def execute(query: String): Unit
}

private class PostgresConnection(host: String, port: Int) extends DatabaseConnection {
  override def execute(query: String): Unit = 
    println(s"Executing on Postgres at $host:$port: $query")
}

private class MySQLConnection(host: String, port: Int) extends DatabaseConnection {
  override def execute(query: String): Unit = 
    println(s"Executing on MySQL at $host:$port: $query")
}

object DatabaseConnection {
  private val connectionPool = scala.collection.mutable.Map[String, DatabaseConnection]()
  
  def apply(dbType: String, host: String, port: Int): Option[DatabaseConnection] = {
    val key = s"$dbType:$host:$port"
    
    connectionPool.get(key).orElse {
      val connection = dbType.toLowerCase match {
        case "postgres" => Some(new PostgresConnection(host, port))
        case "mysql" => Some(new MySQLConnection(host, port))
        case _ => None
      }
      connection.foreach(conn => connectionPool(key) = conn)
      connection
    }
  }
  
  def clearPool(): Unit = connectionPool.clear()
}

// Usage
val conn1 = DatabaseConnection("postgres", "localhost", 5432)
val conn2 = DatabaseConnection("postgres", "localhost", 5432) // Returns cached instance
conn1.foreach(_.execute("SELECT * FROM users"))

The Apply and Unapply Pattern

The apply method enables object construction without the new keyword, while unapply enables pattern matching by extracting values from objects.

case class Email(user: String, domain: String) {
  override def toString: String = s"$user@$domain"
}

object Email {
  def apply(emailString: String): Option[Email] = {
    emailString.split("@") match {
      case Array(user, domain) if user.nonEmpty && domain.contains(".") =>
        Some(new Email(user, domain))
      case _ => None
    }
  }
  
  def unapply(email: Email): Option[(String, String)] = {
    Some((email.user, email.domain))
  }
}

// Pattern matching with custom extractor
def analyzeEmail(emailStr: String): String = {
  Email(emailStr) match {
    case Some(Email(user, domain)) if domain.endsWith(".com") =>
      s"Commercial email for user: $user"
    case Some(Email(user, domain)) if domain.endsWith(".edu") =>
      s"Educational email for user: $user"
    case Some(Email(user, _)) =>
      s"Other email for user: $user"
    case None =>
      "Invalid email format"
  }
}

println(analyzeEmail("john@example.com"))  // Commercial email for user: john
println(analyzeEmail("jane@university.edu"))  // Educational email for user: jane

Constants and Configuration

Companion objects serve as natural containers for constants, configuration values, and utility methods related to the class.

class HttpClient(timeout: Int, maxRetries: Int) {
  import HttpClient._
  
  def get(url: String): String = {
    var attempts = 0
    while (attempts < maxRetries) {
      try {
        // Simulate HTTP call
        return s"Response from $url"
      } catch {
        case _: Exception =>
          attempts += 1
          if (attempts >= maxRetries) throw new RuntimeException("Max retries exceeded")
      }
    }
    ""
  }
}

object HttpClient {
  // Constants
  val DEFAULT_TIMEOUT: Int = 30000
  val DEFAULT_MAX_RETRIES: Int = 3
  val USER_AGENT: String = "ScalaHttpClient/1.0"
  
  // Predefined configurations
  val defaultClient: HttpClient = new HttpClient(DEFAULT_TIMEOUT, DEFAULT_MAX_RETRIES)
  val fastClient: HttpClient = new HttpClient(5000, 1)
  val resilientClient: HttpClient = new HttpClient(60000, 5)
  
  // Builder pattern
  class Builder {
    private var timeout: Int = DEFAULT_TIMEOUT
    private var maxRetries: Int = DEFAULT_MAX_RETRIES
    
    def withTimeout(t: Int): Builder = { timeout = t; this }
    def withMaxRetries(r: Int): Builder = { maxRetries = r; this }
    def build(): HttpClient = new HttpClient(timeout, maxRetries)
  }
  
  def builder(): Builder = new Builder()
}

// Usage
val client1 = HttpClient.defaultClient
val client2 = HttpClient.builder()
  .withTimeout(10000)
  .withMaxRetries(5)
  .build()

Implicit Conversions and Type Classes

Companion objects are the idiomatic location for implicit conversions and type class instances, leveraging Scala’s implicit resolution rules.

trait JsonSerializer[A] {
  def toJson(value: A): String
}

case class User(id: Int, name: String, email: String)

object User {
  // Implicit type class instance in companion object
  implicit val userJsonSerializer: JsonSerializer[User] = new JsonSerializer[User] {
    def toJson(user: User): String = {
      s"""{"id":${user.id},"name":"${user.name}","email":"${user.email}"}"""
    }
  }
}

case class Product(id: Int, name: String, price: Double)

object Product {
  implicit val productJsonSerializer: JsonSerializer[Product] = new JsonSerializer[Product] {
    def toJson(product: Product): String = {
      s"""{"id":${product.id},"name":"${product.name}","price":${product.price}}"""
    }
  }
}

// Generic serialization function
def serialize[A](value: A)(implicit serializer: JsonSerializer[A]): String = {
  serializer.toJson(value)
}

// Usage - compiler finds implicit in companion object
val user = User(1, "Alice", "alice@example.com")
val product = Product(100, "Laptop", 999.99)

println(serialize(user))     // {"id":1,"name":"Alice","email":"alice@example.com"}
println(serialize(product))  // {"id":100,"name":"Laptop","price":999.99}

Extension Methods via Implicit Classes

Companion objects can host implicit classes that add extension methods to existing types, creating fluent APIs.

object StringOps {
  implicit class RichString(val s: String) extends AnyVal {
    def toSnakeCase: String = {
      s.replaceAll("([A-Z])", "_$1").toLowerCase.replaceAll("^_", "")
    }
    
    def toCamelCase: String = {
      val parts = s.split("_")
      parts.head + parts.tail.map(_.capitalize).mkString
    }
    
    def truncate(maxLength: Int, suffix: String = "..."): String = {
      if (s.length <= maxLength) s
      else s.take(maxLength - suffix.length) + suffix
    }
  }
}

// Usage
import StringOps._

val camelCase = "userProfileData"
val snakeCase = "user_profile_data"

println(camelCase.toSnakeCase)  // user_profile_data
println(snakeCase.toCamelCase)  // userProfileData
println("This is a long string".truncate(10))  // This is...

Companion objects represent Scala’s elegant solution to the static member problem, providing a fully object-oriented approach while maintaining practical utility. They enable factory patterns, house implicit definitions, and serve as organizational units for class-related functionality. Understanding companion objects is fundamental to writing idiomatic Scala code that leverages the language’s type system and implicit resolution mechanisms effectively.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.