A pure-Scala, cross-platform implementation of HOCON
(Human-Optimized Config Object Notation) β the config format popularized by Lightbend's
com.typesafe:config.
Unlike com.typesafe:config, which is JVM-only, hocon is written in pure Scala 3 β no java.*
imports and no regex engine in its core β so the same parser runs on the JVM, in the
browser/Node.js via Scala.js, and as a native binary via Scala Native. The identical test
suite runs on all three (sbt test).
π Full documentation: hocon.edadma.dev
Status: All seven roadmap phases complete β the lexer, parser, untyped
ConfigAPI, object merging, substitutions, value concatenation, durations/sizes,includedirectives, a typed (case-class) decoder, and HOCON-spec conformance (path expressions,+=append, self-referential substitutions) are in and tested on all three platforms. See the roadmap below.
import io.github.edadma.hocon.*
val config = Hocon.parse("""
en {
greeting = "Hello, world"
nav { home = "Home", about = "About" }
cart.items = "{count} items" # path-expression key
}
""")
config.getString("en.greeting") // "Hello, world"
config.getString("en.nav.home") // "Home"
config.getConfig("en").getInt("...")
config.hasPath("en.missing") // false
// i18n placeholder helper
val msg = Messages(config.getConfig("en"))
msg("cart.items", "count" -> 3) // "3 items"A partial config falls back to a base β perfect for a base locale with per-locale overrides:
val base = Hocon.parse("""greeting = "Hello", farewell = "Goodbye"""")
val fr = Hocon.parse("""greeting = "Bonjour"""")
val messages = Messages(fr.withFallback(base))
messages("greeting") // "Bonjour" (translated)
messages("farewell") // "Goodbye" (from base)
// Merge several layers; later arguments win:
val effective = Hocon.load(base, fr, userOverrides)Objects merge recursively; arrays and scalars replace. A null in the override shadows (unsets)
the fallback value at that key.
${path} references another value in the same document; ${?path} is optional and disappears when
nothing is found. Substitutions resolve against the merged root, so they are order-independent.
val config = Hocon.parse("""
host = localhost
url = ${host} # β "localhost"
service = ${defaults} # copies the whole object
defaults { timeout = 30 }
""")
// A missing required substitution throws; an optional one is simply absent:
Hocon.parse("x = ${?missing}").hasPath("x") // false
// Fall back to the environment for anything not in the config:
Hocon.parse("home = ${HOME}", EnvSource.fromMap(sys.env))Circular references throw CircularReferenceException.
Pieces written side by side with only whitespace between them concatenate. Strings, numbers, and substitutions join into one string (interior whitespace preserved); arrays concatenate element-wise; objects deep-merge left to right.
val config = Hocon.parse("""
host = example.com
port = 8080
url = "http://"${host}":"${port} // β "http://example.com:8080"
xs = [1, 2] [3, 4] // β [1, 2, 3, 4]
""")A key is a path expression: only an unquoted . separates elements, so a quoted segment may
contain a literal dot, and an unquoted key with spaces is one element. A field can refer to its
own prior value, and += appends an element to the array already at a key (HOCON's
key = ${?key} [value] shorthand), looking back across object blocks:
val config = Hocon.parse("""
foo."bar.baz" = 1 // path elements: foo, "bar.baz"
server { ports = [80] }
server { ports += 443 } // β server.ports = [80, 443]
""")
config.getConfig("server").getStringList("ports") // List("80", "443")Per the HOCON spec a document's root may be an object or an array. Hocon.parse returns the
path-addressable Config for the common object-rooted document. When the root may be a top-level
array, use Hocon.parseValue, which returns the resolved root value (ConfigObject or
ConfigArray) directly:
Hocon.parseValue("[1, 2, 3]") // ConfigArray(List(ConfigNumber("1"), ...))
Hocon.parseValue("a = 1") // ConfigObject(...) β object roots work too
Hocon.parse("[1, 2, 3]") // throws WrongTypeException β Config needs an object rootparseValue takes the same optional EnvSource and ConfigSource seams as parse. Inside an array
root there are no keys to look into, so only ${?...} and environment fallbacks resolve.
Read unit-suffixed values with getDuration (a cross-platform FiniteDuration) and getBytes
(a Long):
val config = Hocon.parse("timeout = 10s, cache = 512K")
config.getDuration("timeout") // 10.seconds
config.getBytes("cache") // 524288Duration units are ns/us/ms/s/m/h/d (bare number = milliseconds); size units
distinguish powers of 1024 (K, Ki, KiB) from powers of 1000 (kB, MB).
include "other.conf" pulls another document in at that point, merging its fields so later fields
override them. The qualified forms pin the lookup, and required(...) errors instead of skipping a
missing target. Where includes are read from goes through a ConfigSource you pass to parse; the
default reads files identically on every platform:
import io.github.edadma.hocon.*
// Read files with the platform default source (identical on every platform):
val config = Hocon.parse("""include "app.conf"""", ConfigSource.default)
// Or resolve includes from memory β identical on every platform, ideal for tests:
val src = ConfigSource.fromMap(Map("app.conf" -> """name = "demo""""))
Hocon.parse("""include "app.conf"""", src).getString("name") // "demo"ConfigSource.empty (the default for Hocon.parse(text)) does no IO: optional includes are skipped
and a required(...) one raises IncludeException. Pass EnvSource.system alongside to let
${VAR} substitutions fall back to the real process environment.
A note on quoting: HOCON allows unquoted strings, but forbids the characters
$ " { } [ ] : = , + # ^ ? ! @ * &and` inside them. Most UI strings (Hello, world,
Are you sure?, {count} items) hit one of these, so quote your translation strings. This is
spec-correct HOCON, not a limitation of this library.
Map a whole document onto a case class with config.as[A]. A decoder is derived from the case
class at compile time (pure Mirror work, so it runs the same on all three platforms), reading
each field from the object key of the same name and recursing into nested case classes:
import io.github.edadma.hocon.*
import scala.concurrent.duration.*
case class Server(host: String, port: Int, debug: Boolean)
case class App(name: String, server: Server, tags: List[String], timeout: FiniteDuration)
val config = Hocon.parse("""
name = demo
server { host = localhost, port = 8080, debug = true }
tags = [http, public]
timeout = 30s
""")
config.as[App]
// App("demo", Server("localhost", 8080, true), List("http", "public"), 30.seconds)String, Int/Long/Double, Boolean, FiniteDuration, Option (absent or null β
None), List, Map[String, _], Config, and nested case classes are supported. Failures
throw MissingPathException / WrongTypeException carrying the dotted field path. Use
config.getAs[A](path) to decode a value that isn't at the root.
HOCON is a comfortable format for configuration and, in particular, for internationalization (i18n) message files β nested, commented, human-friendly:
# messages.en.conf
en {
greeting = "Hello, world"
nav { home = "Home", about = "About" }
cart.items = "{count} items" # path-expression key
}Until now there has been no good way to read these on Scala.js or Scala Native. That's the gap hocon fills.
| Phase | Scope | Status |
|---|---|---|
| 1 | Lexer + parser β untyped Config (comments, quoted/unquoted strings, nested objects, path-expression keys, arrays). i18n-usable. |
β |
| 2 | Object merging + withFallback (base locale + overrides). |
β |
| 3 | Substitutions: ${path}, ${?path}, env fallback, cycle detection. |
β |
| 4 | Value concatenation + durations (10s) and sizes (512K, 10MB). |
β |
| 5 | include directives behind a pluggable, per-platform IO source. |
β |
| 6 | Typed decoder with case-class derivation (config.as[A]). |
β |
| 7 | HOCON-spec conformance: path expressions, += append, self-referential substitutions. |
β |
The full documentation site is at hocon.edadma.dev:
- Getting Started β add the dependency and parse your first config
- Guide β the HOCON format, merging, substitutions, includes, typed decoding, and the roadmap
- Reference β the
ConfigAPI and theMessagesi18n helper
sbt hoconJVM/test
sbt hoconJS/test
sbt hoconNative/testISC β see LICENSE.