Scala Object Notation is subset of Scala programming language that can decode directly into data.
Scala Object Notation supports writing a Scala source files in two modes:
- a single one top-level
valdeclaration, optionally preceded by a single package statement; - or a top-level expression.
package example.config
/** 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:
- an optional single
package foo.barstatement before a declaration - 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 =
"""package example.config
|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", packageName = "example.config")If you don't require a package statement, then omit the packageName argument (its default value is "").
Package statements are always rejected by top-level expression readers such as readAs and quick.read.
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.
The plain Readers methods allocate a fresh decoder for each call. That is a good default for
one-off reads because no mutable decode state is retained after the call returns.
When reading many values with the same process, use Readers.batched with a BatchContext to
reuse decoder machinery across calls. The result values and errors are identical to the plain API;
only allocation behaviour changes.
import scalanotation.*
val inputs = IArray(
"""(region = "eu-central", weight = 2)""",
"""(region = "us-east", weight = 1)"""
)
given BatchContext = BatchContext.local()
val decoded = inputs.map: input =>
Readers.batched.readAs[(region: String, weight: Int)](input)BatchContext.local() is the cheapest pooled context for a batch confined to one thread. Do not
share it between threads.
For concurrent batches, use a shared context:
given BatchContext = BatchContext.shared()BatchContext.shared(capacityHint) uses a fixed-capacity lock-free pool. When more decodes run
concurrently than the pool can hold, the excess decodes allocate temporary decoder instances and
leave them to the garbage collector.
For completeness, BatchContext.garbageCollected is the no-pooling context. The plain Readers
API is equivalent to using it.
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 TupleExpr(elements: IndexedSeq[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), ...)))
val tuple = Readers.readAs[scalanotation.Expr]("""1 *: "two" *: Vector(3) *: EmptyTuple""")
assert(tuple == TupleExpr(Vector(IntConstant(1), StringConstant("two"), ...)))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", packageName = "example.config")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 ReadWriterDerived case class readers and read-writers keep fields ordered and required by default. To allow
nullable Option fields to be skipped while decoding, opt in with the skippable namespace:
case class Config(host: String, port: Option[Int])
given Reader[Config] = Reader.skippable.derivedUse ReadWriter.skippable.derived for bidirectional schemas.
That 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)Use Reader.configured.derived, Writer.configured.derived, or
ReadWriter.configured.derived when a type should use an explicit Configured[T].
Currently, configuration supports:
- discriminator-field encoding for sum types
- skippable decoding for product fields
- opt-in typed factories for lower-boxing product construction
Discriminator-field encoding flattens the selected enum case into the surrounding named tuple. The discriminator field is written first and is not preserved in the decoded value:
import scalanotation.*
enum Mode:
case Fast
case Scheduled(at: String, retries: Int)
given Configured[Mode] =
Configured.discriminator("type")
given ReadWriter[Mode] =
ReadWriter.configured.derivedThe encoded form is:
// Fast
(`type` = "Fast")
// Scheduled(at = "2026-03-15", retries = 2)
(`type` = "Scheduled", at = "2026-03-15", retries = 2)Configured.discriminator[T] is only available for sum types.
Configured derivation can also be skippable:
case class User(name: String, nickname: Option[String])
given Configured[User] =
Configured.skippable
given Reader[User] =
Reader.configured.derivedThat reader accepts:
(name = "Ada")For product types, skippable configured derivation still requires at least one non-Option field.
For discriminator sum types, the discriminator field is enough, so product cases may contain only
optional fields:
enum Event:
case Ping(id: Option[Int], label: Option[String])
given Configured[Event] =
Configured.discriminator("type", skippable = true)
given Reader[Event] =
Reader.configured.derivedThis accepts either:
(`type` = "Ping")
(`type` = "Ping", id = 1)Decoding in batched mode already shares reusable intermediate buffers with typed slots for primitive
values, however by default derived Reader instances use scala.deriving.Mirror.Product's
fromProduct method, which passes all values through the productElement(index: Int): Any method.
Without inlining and escape analysis, this will box primitive values, so an alternative
TypedFactory[T] typeclass exists that gives unboxed accessors to builder state.
This can give a measurable reduction in allocation count for classes with many primitive fields.
You can opt in to a lower-boxing path by deriving a TypedFactory[T] and attaching it to the
configured reader. This is an example using the macros package, and attaching
to a Configured instance:
import scalanotation.*
import scalanotation.macros.TypedFactories
final case class Endpoint(host: String, port: Int, secure: Boolean)
given TypedFactory[Endpoint] = TypedFactories.derived
given Configured[Endpoint] = Configured.typed
given Reader[Endpoint] = Reader.configured.derived
given BatchContext = BatchContext.local()
val decoded =
Readers.batched.readAs[Endpoint]("""(host = "localhost", port = 8080, secure = true)""")Configured.typed is equivalent to Configured.default.withTypedFactories. It is most useful with
Readers.batched, where the decoder can reuse its typed product slots across reads.
Typed factories also compose with other configured derivation modes:
enum Shape:
case Circle(radius: Double)
case Rect(width: Int, height: Int)
case Dot
given TypedFactory[Shape] = TypedFactories.derived
given Configured[Shape] =
Configured.discriminator[Shape]("type").withTypedFactories
given Reader[Shape] =
Reader.configured.derivedFor sums, the derived TypedFactory stores typed factories for the structured product cases.
Nullary cases still decode as fixed nullary values and do not need a product factory.
BuilderSlots is the decoder-owned product buffer behind the batched and typed decoding paths. A
slot records both the value and its kind. Reference values are stored as references, while primitive
values are packed without boxing.
You can either manually provide a TypedFactory yourself, or derive one from
the scalanotation.macros package.
import scalanotation.*
final case class Point(x: Int, y: Int)
given TypedFactory.OfProduct[Point]:
def fromSlots(slots: BuilderSlots): Point =
Point(slots.getInt(0), slots.getInt(1))A TypedFactory must treat BuilderSlots as borrowed decoder state: read the values during
fromSlots and do not store the BuilderSlots instance anywhere. In pooled batched decoding, the
same slot buffer may be reused for later decodes.
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", packageName = "example.config").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:
- if
packageNameis supplied for declaration parsing, the package statement must match exactly - 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