Scala - Environment Variables

• Scala provides multiple approaches to access environment variables through `sys.env`, `System.getenv()`, and property files, each with distinct trade-offs for type safety and error handling

Key Insights

• Scala provides multiple approaches to access environment variables through sys.env, System.getenv(), and property files, each with distinct trade-offs for type safety and error handling • Environment-specific configuration should leverage libraries like Typesafe Config or PureConfig for production applications, offering validation, fallback values, and type-safe parsing • Proper environment variable management requires defensive programming with Option types, explicit error handling, and clear separation between development and production configurations

Accessing Environment Variables with sys.env

Scala’s sys.env provides an immutable Map-like interface to environment variables. This is the most idiomatic Scala approach, returning Map[String, String].

object EnvBasics extends App {
  // Direct access - throws NoSuchElementException if missing
  val home = sys.env("HOME")
  println(s"Home directory: $home")
  
  // Safe access with get - returns Option[String]
  val editor = sys.env.get("EDITOR")
  editor match {
    case Some(e) => println(s"Editor: $e")
    case None => println("EDITOR not set")
  }
  
  // With default value
  val port = sys.env.getOrElse("PORT", "8080")
  println(s"Port: $port")
  
  // Check existence
  if (sys.env.contains("DATABASE_URL")) {
    println("Database configured")
  }
}

The get method returns Option[String], forcing you to handle the missing variable case explicitly. This prevents runtime errors and makes your code more robust.

Type-Safe Environment Variable Parsing

Environment variables are strings, but applications need typed values. Here’s a pattern for safe parsing with error accumulation:

import scala.util.{Try, Success, Failure}

case class DatabaseConfig(
  host: String,
  port: Int,
  username: String,
  password: String,
  maxConnections: Int
)

object ConfigParser {
  def parseEnv(): Either[List[String], DatabaseConfig] = {
    val errors = scala.collection.mutable.ListBuffer[String]()
    
    val host = sys.env.get("DB_HOST") match {
      case Some(h) if h.nonEmpty => Some(h)
      case _ => 
        errors += "DB_HOST is required"
        None
    }
    
    val port = sys.env.get("DB_PORT").flatMap { p =>
      Try(p.toInt) match {
        case Success(port) if port > 0 && port < 65536 => Some(port)
        case _ => 
          errors += s"DB_PORT must be valid port number, got: $p"
          None
      }
    }.orElse {
      errors += "DB_PORT is required"
      None
    }
    
    val username = sys.env.get("DB_USERNAME").filter(_.nonEmpty).orElse {
      errors += "DB_USERNAME is required"
      None
    }
    
    val password = sys.env.get("DB_PASSWORD").orElse {
      errors += "DB_PASSWORD is required"
      None
    }
    
    val maxConn = sys.env.get("DB_MAX_CONNECTIONS")
      .flatMap(s => Try(s.toInt).toOption)
      .getOrElse(10) // default value
    
    if (errors.nonEmpty) {
      Left(errors.toList)
    } else {
      Right(DatabaseConfig(
        host.get, 
        port.get, 
        username.get, 
        password.get, 
        maxConn
      ))
    }
  }
}

// Usage
ConfigParser.parseEnv() match {
  case Right(config) => 
    println(s"Database configured: ${config.host}:${config.port}")
  case Left(errors) => 
    System.err.println("Configuration errors:")
    errors.foreach(e => System.err.println(s"  - $e"))
    sys.exit(1)
}

This approach collects all configuration errors at once, providing better developer experience than failing on the first error.

Using Typesafe Config Library

For production applications, use Typesafe Config (now Lightbend Config). It supports HOCON format, environment variable substitution, and fallback chains.

// build.sbt
libraryDependencies += "com.typesafe" % "config" % "1.4.3"
import com.typesafe.config.{Config, ConfigFactory}

object TypesafeConfigExample extends App {
  // Loads application.conf with environment variable overrides
  val config: Config = ConfigFactory.load()
  
  // Access values with type safety
  val dbHost = config.getString("database.host")
  val dbPort = config.getInt("database.port")
  val enableCache = config.getBoolean("cache.enabled")
  val timeout = config.getDuration("http.timeout")
  
  // With fallback
  val maxRetries = if (config.hasPath("retry.max")) {
    config.getInt("retry.max")
  } else {
    3
  }
  
  println(s"Connecting to $dbHost:$dbPort")
}

Create src/main/resources/application.conf:

database {
  host = "localhost"
  host = ${?DATABASE_HOST}
  port = 5432
  port = ${?DATABASE_PORT}
  username = ${DB_USERNAME}
  password = ${DB_PASSWORD}
  pool {
    size = 10
    size = ${?DB_POOL_SIZE}
  }
}

cache {
  enabled = false
  enabled = ${?CACHE_ENABLED}
}

http {
  timeout = 30s
  timeout = ${?HTTP_TIMEOUT}
}

The ${?VAR} syntax means “substitute if the environment variable exists, otherwise use the previous value.” This provides sensible defaults with environment override capability.

PureConfig for Case Class Mapping

PureConfig automatically maps configuration to case classes using type classes:

// build.sbt
libraryDependencies += "com.github.pureconfig" %% "pureconfig" % "0.17.4"
import pureconfig._
import pureconfig.generic.auto._
import scala.concurrent.duration.FiniteDuration

case class HttpConfig(
  host: String,
  port: Int,
  timeout: FiniteDuration,
  maxConnections: Int
)

case class DatabaseConfig(
  host: String,
  port: Int,
  username: String,
  password: String,
  poolSize: Int
)

case class AppConfig(
  http: HttpConfig,
  database: DatabaseConfig,
  logLevel: String
)

object PureConfigExample extends App {
  ConfigSource.default.load[AppConfig] match {
    case Right(config) =>
      println(s"HTTP server: ${config.http.host}:${config.http.port}")
      println(s"Database: ${config.database.host}")
      println(s"Pool size: ${config.database.poolSize}")
      
    case Left(errors) =>
      println("Configuration errors:")
      errors.toList.foreach(println)
      sys.exit(1)
  }
}

PureConfig handles type conversion, nested structures, and provides detailed error messages for invalid configurations.

Environment-Specific Configuration Files

Manage multiple environments with profile-specific configuration files:

object ConfigLoader {
  def load(): Config = {
    val env = sys.env.getOrElse("APP_ENV", "development")
    val envConfig = ConfigFactory.parseResources(s"application.$env.conf")
    val baseConfig = ConfigFactory.parseResources("application.conf")
    
    ConfigFactory
      .systemEnvironment()  // Highest priority
      .withFallback(envConfig)
      .withFallback(baseConfig)
      .resolve()
  }
}

Create environment-specific files:

  • application.conf - base configuration
  • application.development.conf - development overrides
  • application.production.conf - production overrides
// application.development.conf
include "application.conf"

database {
  host = "localhost"
  username = "dev_user"
  password = "dev_password"
}

logLevel = "DEBUG"
// application.production.conf
include "application.conf"

database {
  host = ${DATABASE_HOST}
  username = ${DATABASE_USERNAME}
  password = ${DATABASE_PASSWORD}
}

logLevel = "INFO"

Testing with Environment Variables

Mock environment variables in tests using system properties or test configurations:

import org.scalatest.flatspec.AnyFlatSpec
import org.scalatest.matchers.should.Matchers

class ConfigTest extends AnyFlatSpec with Matchers {
  "DatabaseConfig" should "parse valid environment variables" in {
    // Set up test environment
    val testEnv = Map(
      "DB_HOST" -> "testhost",
      "DB_PORT" -> "5432",
      "DB_USERNAME" -> "testuser",
      "DB_PASSWORD" -> "testpass"
    )
    
    // Create config with test values
    val config = ConfigFactory.parseMap(
      testEnv.asJava
    ).resolve()
    
    config.getString("DB_HOST") shouldBe "testhost"
    config.getInt("DB_PORT") shouldBe 5432
  }
  
  it should "fail with clear errors for invalid port" in {
    val result = parseWithEnv(Map(
      "DB_HOST" -> "localhost",
      "DB_PORT" -> "invalid",
      "DB_USERNAME" -> "user",
      "DB_PASSWORD" -> "pass"
    ))
    
    result.isLeft shouldBe true
    result.left.get should contain("DB_PORT must be valid port number")
  }
  
  private def parseWithEnv(env: Map[String, String]): Either[List[String], DatabaseConfig] = {
    // Implementation that accepts Map instead of sys.env
    // for testability
    ???
  }
}

For integration tests, use Docker containers or test containers to provide realistic environment configurations without polluting the system environment.

Liked this? There's more.

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