Scala Object Notation is subset of Scala programming language that can decode directly into data.
Scala Object Notation supports writing a Scala source file with one top-level val declaration:
/** Comments are supported! */
val conf = (
app = (
host = "127.0.0.1",
port = 8080,
// mode = "prod",
mode = "dev",
replicas = Vector(
(region = "eu-central", weight = 2),
(region = "us-east", weight = 1)
)
),
schedule = (
start = "2021-12-15",
refreshSeconds = null
),
features = Vector("metrics", "tracing"),
)Why Scala syntax?
- they can be compiled and introspected as part of a program or decoded by external programs.
- structural data can be interpreted via a schema to richer types (via
ReaderandWritertype classes) or used as is. - config can be edited programatically and written back
- schema checking errors index to the position in the source file
- named tuples have strict ordering and non-duplication requirements.
There are no methods (yet?) only pure data.
The supported syntax is deliberately small:
- one top-level
valdeclaration - named tuples for structured objects,
Vector(...)for sequences,- scala literal values
- string concatenation
If your config structure already matches Scala data closely, you can decode directly into a named tuple type. This is the most direct config workflow: parse the file and get back a nested typed structure without defining intermediate case classes.
import scalanotation.*
import steps.result.Result
val input =
"""val conf = (
| app = (
| host = "127.0.0.1",
| port = 8080,
| mode = "dev",
| replicas = Vector(
| (region = "eu-central", weight = 2),
| (region = "us-east", weight = 1)
| )
| ),
| schedule = (
| start = "2021-12-15",
| refreshSeconds = null
| ),
| features = Vector("metrics", "tracing"),
|)
|""".stripMargin
type Config =
(
app: (
host: String,
port: Int,
mode: String,
replicas: Vector[(region: String, weight: Int)]
),
schedule: (
start: String,
refreshSeconds: Option[Int]
),
features: Vector[String],
)
val decoded: Result[Config, DecodeError] =
Readers.readDeclAs[Config](input, rootName = "conf")This direct structural decoding is especially useful when your config is already naturally tree-shaped and you want Scala’s nested named tuple types to mirror the file exactly.
scalanotation.Expr is an algebraic data type representing the syntax of Scala Object Notation:
enum Expr:
case NamedTupleExpr(elements: IndexedSeq[(name: String, value: Expr)])
case VectorExpr(elements: IndexedSeq[Expr])
case StringConstant(value: String)
case CharConstant(value: Char)
case IntConstant(value: Int)
case LongConstant(value: Long)
case FloatConstant(value: Float)
case DoubleConstant(value: Double)
case BooleanConstant(value: Boolean)
case NullConstantit can also be directly decoded to from text:
val decoded = Readers.readAs[scalanotation.Expr]("(ok = true, retries = 3)")
assert(decoded == NamedTupleExpr(Vector("ok" -> BooleanConstant(true), ...)))Traditional application config parsing in Scala decodes to domain types rather than raw data, which is supported by Scala Object Notation.
The library provides the ReadWriter[T] type class which declares a schema for the shape of data.
supporting both reading and writing. Reader[T] and Writer[T] also exist to restrict capabilities
to one way.
Both type classes can be derived automatically, or allow you to transform an existing schema.
import scalanotation.*
import steps.result.Result
import java.time.LocalDate
enum Mode:
case Dev, Prod
// map the existing `ReadWriter[String]`:
given ReadWriter[Mode] =
summon[ReadWriter[String]].bimapResult {
case "dev" => Result.Ok(Mode.Dev)
case "prod" => Result.Ok(Mode.Prod)
case other => Result.Err(DecodeError.Custom(s"Unknown mode '$other'"))
} {
case Mode.Dev => "dev"
case Mode.Prod => "prod"
}
// map the existing `ReadWriter[String]`:
given ReadWriter[LocalDate] =
summon[ReadWriter[String]].bimapResult { raw =>
Result.catchException({ case _: java.time.format.DateTimeParseException =>
DecodeError.Custom(s"Invalid ISO date '$raw'")
}) {
LocalDate.parse(raw)
}
}(_.toString)
// semi-automatic derivation
case class Replica(region: String, weight: Int) derives ReadWriter
case class App(host: String, port: Int, mode: Mode, replicas: Vector[Replica]) derives ReadWriter
case class Schedule(start: LocalDate, refreshSeconds: Option[Int]) derives ReadWriter
case class Config(app: App, schedule: Schedule, features: Vector[String]) derives ReadWriter
// decode the same input as before to a richer type
val decoded = Readers.readDeclAs[Config](input, rootName = "conf")That lets you keep the text format simple while still decoding into domain-specific Scala types.
The same typed values can be written back out as Scala Object Notation text, or to an Expr.
import scalanotation.*
val value =
(
app = (host = "127.0.0.1", port = 8080, mode = "dev"),
features = Vector("metrics", "tracing"),
refreshSeconds = Option.empty[Int]
)
val expr: Expr = Writers.writeExpr(value) // write to dynamic data
val text: String = Writers.write(value) // write to expression
val declText: String = Writers.writeDecl("conf", value) // write to declaration of name "conf"
val prettyDecl: String = Writers.writeDeclPretty("conf", value, indent = 2)Expr values can also be rendered directly:
import scalanotation.*
val expr = Writers.writeExpr((ok = true, retries = 3))
val compact = expr.render
val pretty = expr.renderPretty(indent = 2)
val alsoPretty = expr.render(TextFormat.pretty(indent = 2))You can derive the type classes for product and sum types:
derives Readerfor read-only usederives Writerfor write-only usederives ReadWriterfor both directions
ReadWriter is usually the best fit for config models because it keeps both directions aligned.
Case classes derive structurally from their fields:
import scalanotation.*
case class Database(host: String, port: Int) derives ReadWriter
case class Config(database: Database, debug: Boolean) derives ReadWriterThat corresponds to config shaped like:
val conf = (
database = (
host = "localhost",
port = 5432
),
debug = true
)Enums derive as a single-field object whose field name is the case label.
import scalanotation.*
enum Mode derives ReadWriter:
case Fast
case Scheduled(at: String, retries: Int)The encoded form is:
// Fast
(Fast = null)
// Scheduled(at = "2026-03-15", retries = 2)
(Scheduled = (at = "2026-03-15", retries = 2))Case objects and empty products follow the same nullary representation:
// case object Foo
(Foo = null)If a config field should still be represented as a simple scalar in text, but map to a richer type in Scala, build on an existing type class:
// convert result of decoding
val r: Reader[T]
r.map(...); r.mapResult(...)
// convert input that will be passed to an encoder
val w: Writer[T]
w.contraMap(...)
// map in both directions
val rw: ReadWriter[T]
rw.bimap(...)(...); rw.bimapResult(...)(...)Typical examples are dates, paths, IDs, and string-backed enums:
import scalanotation.*
import steps.result.Result
final case class UserId(value: Int)
given ReadWriter[UserId] =
summon[ReadWriter[Int]].bimap(UserId(_))(_.value)If you want a generic syntax tree first, read into Expr and decode later:
import scalanotation.*
val expr = Readers.readDeclAs[Expr](input, rootName = "conf").get
val decoded = expr.decodeAs[(ok: Boolean, retries: Int)]This is useful if you want to inspect, transform, or export a config before decoding it into a final domain type.
Typed decoding is intentionally strict:
- the requested root declaration name must match
- named tuple field names must match exactly
- field count must match exactly
- field order must match exactly
- duplicate fields are rejected
- only
Intliterals, plusDoubleliterals when reading asFloat, are promoted across numeric targets, and only when exact
Errors include useful context:
- decode errors include nested paths such as
.database.hostor.items[1] - token and parse errors include line and column information
This is especially useful for configuration, the exact location of an error helps a user correct the issue.
The library supports decoding and writing for:
- arbitrary Scala 3 named tuples, including deeply nested named tuple and
Vectorcombinations - case classes, case objects, and enums via derivation
String,Char,Int,Long,Float,Double,Boolean, andNullOption[T]Vector[T]Array[T]andIArray[T]- arbitrary
scala.collection.Seq[T] - arbitrary
scala.collection.Map[String, T] Expr- custom types via mapping from an existing type class
Scala Object Notation is meant to stay import-free and data-oriented. That is why it does not
support constructors (e.g. Foo(1)) or references (e.g. Bar).
Keeping the format narrow has a few benefits:
- the document is clearly raw data, not executable code
- generic tooling can work without needing application-specific symbol resolution
- config stays copy-pasteable into ordinary Scala files without extra imports
- schema derivation remains structural and predictable
The demo module provides a small CLI for converting from Scala Object Notation to other
data formats (YAML, JSON):
./mill demo.run example/config.scala --name confAvailable options:
--name <value>: required root declaration name--tokens: print the token stream before parsing--json: render the parsed value as JSON--yaml: render the parsed value as YAML--safe-nums: preserve lossy JSON numeric cases as strings where relevant
Examples:
./mill demo.run example/config.scala --name conf --tokens
./mill demo.run example/config.scala --name conf --json
./mill demo.run example/config.scala --name conf --yaml
./mill demo.run example/config.scala --name conf --json --safe-numscore: tokenizer, AST, parser, schema derivation, decoding, and writingdemo: CLI for parsing config files and exporting JSON or YAMLexample/config.scala: minimal sample input for the demo
The code lives in the scalanotation package.
This project uses Mill.
Run all tests:
./mill __.testCompile the core module:
./mill core.compileCompile the demo module:
./mill demo.compile