Report

Where are we going with the Standard Library? ✈️

Jamie Thompson
Scala Center

Scala Days 2026

A project supported by the Sovereign Tech Agency

Timeline: Nov 2025 - Feb 2027

Screenshot of announcement

Everyone working on the project

Project Goals

Stdlib

  • unfreeze core APIs
  • community process

Compiler

  • optimise runtime code
  • capture checking rules

Toolkit

  • concurrency primitives
  • other library candidates

1016 hours limit

if one person: 2-3 days a week for a year

A process of exploration

Interviews 🎤❓

  • Martin Odersky
  • Haoyi Li
  • Tomasz Godzik
  • Seth Tisue
  • Many Scalar Conference Attendees

Research 🔬

look for news, compare communities

Ideation 🧠

  • Draw from experience, interviews, research
  • Make hypotheses about useful gaps.

Experiment 🧪

Build real apps to test assumptions.

Unfreezing the library

Backstory: a tale of two libraries

The same source code compiled by Scala 2.13 and Scala 3 produce different binary outputs.

Shared Scala 2.13 library

Frozen API since 2019!

List · Option · Future · Either · Try

Separate Scala 3 library

Evolvable, thanks to TASTy compatibility

IArray · Enum · NamedTuple · Mirror

  1. 2.13
  2. 3.0
  3. 3.1
  4. 3.2
  5. 3.3
  6. 3.4
  7. 3.5
  8. 3.6
  9. 3.7

Open question: bincompat for shared library compiled by Scala 3.

Now: opening up

Nov 19 2025

Unified Standard Library in Scala 3.8.0-RC1

  • one library
  • capture checking needs Scala 3 compilation
  • scala 2 binary compatibility mode

Now: opening up

Feb 5 2026

Standard Library Changes (SLC).

gather community feedback for API changes.

Community SLC proposals

Community SLCs

ProposalAuthorPR statePR
into modifier for Factory / BuildFrom@halotukozakMERGED#26326
<, <=, >, >= compiletime ops for String@wtetznerMERGED#24748
Seq(1,2,3) backed by Vector@lihaoyiDRAFT#26386
Buffer(1,2,3) backed by ArrayDeque
& merge Deque API with Buffer.
@lihaoyiDRAFT#25034
.andThen for multi-argument functions@hepin1989DRAFT#26077
Tuple(1,2,3...,99) constructor/pattern@rsshDRAFT#24874
Replace *Ops wrappers with extensions@halotukozakOPEN#26637
Derive ToExpr / FromExpr@halotukozakOPEN#26541
More SizeCompareOps overloads@marconilannaDECLINED#25524
Shared Function.identity@hepin1989DECLINED#26076
Plugin phases in typeCheckErrors@soronpo↩️ Withdrawn#26538

SLC: our changes too

Dec 2025

IArray collection integration

  • Scala 3.10.0 ✅
  • BuildFrom, Factory, and IsSeq implementations.

Now possible with IArray...

val nums: IArray[Int] =
  List(1,2,3,4).to(IArray)

Random.shuffle(nums)

nums
  .lazyZip(nums.tail)
  .map(_ + _)

Prior suggestions

Collected 105 suggestions

  • Evaluation by Bill Venners, we discussed potential improvements and implications.
  • Followed by draft implementations.

Sources:

  • Scala Contributors forum
  • scala/scala-collection-contrib
  • scala/scala-library-next.
  • scala/bug.

Prior suggestions: draft implementations

ProposalProposed by
math.clamp@exoego
Map.unionWith@jackkoenig
String.splitToIArray@BalmungSan
Iterable.frequencies@spamegg1
mutable.Map.merge@erikvanoosten
Iterable.takeTo/dropTo@dcsobral
Iterable.runs@exoego
Iterable.lazyZipAll@neontorrent
Iterable.zipStrict@jackkoenig

Prior suggestions: draft implementations

ProposalProposed by
Iterable.mapWithIndex@BalmungSan
Iterable.mapAccumulate@jackkoenig
Iterable.groupFlatMap@ashleymercer
Map.getAndRemove@benhutchison
Map.invert@soronpo
Set.fullIntersection@Lasering
SeqOps.deleted/updatedWith/splitAround@charpov
Iterable.groupByOrdered@soronpo
SeqSet trait@NthPortal, @SethTisue

scala-reflect still haunts Scala 3 👻

GitHubdotty-staging/dotty:scala-reflect-port

GitHubjoan38/wick

wick library wraps Spark with typesafe datasets.

Unfortunately broken on Scala 3.8+ due to the unified Scala library.

PROTOTYPE

scala-reflect ported to Scala 3

100% test coverage, drop-in, enables Wick to load.

should we release this? 🤠

Improving performance

Improving Performance: compiler

Mar 19 2026

New bytecode optimizer.

  • Available in Scala 3.8.3+
  • Opt-in, eliminates abstraction overhead such as lambdas.

Improving Performance: library

Inlined arrow syntax · ✅ Scala 3.10.0

55.1 -> 60.2
new Tuple2$mcDD$sp(55.1, 60.2)
  • Works with tuple specialization.
  • Still in testing.

Improving Performance: library

Inline pipe/tap · DRAFT PR scala/scala3#24725

foo().tap(println)

Eliminate Range.foreach · DRAFT branch
dotty-staging:inline-range-foreach-miniphase

for i <- 0 to 10 do println(i)
val res = foo()
println(res)
res
var i = 0
while i <= 10 do
  println(i)
  i += 1

Structured Concurrency

Gears: direct style structured concurrency

GitHublampepfl/gears

procedural tasks based on continuations

May 2026 · v0.3.1 · all platforms

  • structured concurrency API
  • group cancellation

Next · async I/O · static safety

  • keep threads busy while I/O awaits
  • capture checking support

Async I/O: Two kernel models

GitHublampepfl/gears

Readiness

Kqueue · epoll · Java NIO Selector

User space

Kernel space

Watch read readiness

Blocking wait · collect events

Ready events · one batch

read₁ · 1 syscall

read₂ · 1 syscall

read₃ · 1 syscall

Process results
+ other work

Watch → wait → N read syscalls.

Commands

io_uring (Linux only)

User space

Kernel space

read₁ · read₂ · read₃

Submit · 1 syscall · no completion wait

Other work

Perform I/O

Results as reads finish

Process queued results
No syscall

1 submission syscall for N reads.

Abstraction Babyyyyy

Proposal: Reactor model · interleave compute and I/O

GitHublampepfl/gears

One thread per reactor · queue compute tasks, pending I/O, and timers

Event queue

Dispatch on the reactor thread

Task A · Task B

Task A calls perform(read) · A suspends

Task B

Run other work · A's read is still pending

I/O finishes → completion enters the event queue

Completion A

Handle the outcome · schedule A to resume

Task A · resumed

Process the result · continue after perform

Kqueue: readiness → perform I/O → completion

io_uring: submit I/O → completion · design target

Reactor + Gears: direct-style async

GitHublampepfl/gears

RPC: write request to server, wait for response

multiplatform API · (JVM, Native, Wasm)

ReactorFutures.run { r =>
  while buf.hasRemaining() do
    perform(r,
      r.ops.write(socket, buf))
  var running = true
  while running do
    buf.clear()
    perform(r, r.ops.read(socket, buf))
    running = buf.position() != 0
    if running then
      buf.flip()
      drainTo(buf, response)
}

run creates an event queue "reactor" r on the current thread, scheduling its argument.

Stage 1: loop to write to socket

perform registers the command to the reactor, and suspends the task. Reactor resumes it post-completion.

write creates a command, upon resume I/O was already performed.

Appears like blocking code: concurrency is abstracted.

Reactor + Gears: direct-style async

GitHublampepfl/gears

RPC: write request to server, wait for response

multiplatform API · (JVM, Native, Wasm)

ReactorFutures.run { r =>
  while buf.hasRemaining() do
    perform(r,
      r.ops.write(socket, buf))
  var running = true
  while running do
    buf.clear()
    perform(r, r.ops.read(socket, buf))
    running = buf.position() != 0
    if running then
      buf.flip()
      drainTo(buf, response)
}

Stage 2: loop to read from socket

Keep performing read until nothing is written to buf.

drain after each read to the response.

Appears like blocking code: concurrency is abstracted.

Future Plans

GitHublampepfl/gears

1 · I/O API

Refine Reactor across Native, JVM, JS / Wasm.

Higher level API over I/O devices.

Implement and test the backends, including Linux io_uring work.

2 · High-level Streams

Try Ox Flow integration.

Test backpressure, cancellation and buffer lifetimes in real pipelines.

3 · HTTP

Minimal localhost HTTP/2 client and server.

WebSocket with streaming support.

Structured concurrency in Gears: static safety

GitHublampepfl/gears

def onWorkerPool(
  body: () ->{any.except[ThreadLocal]} Unit
): Unit =
  workerPool.execute(() => body())

ReactorFutures.run { r => // reactor thread
  onWorkerPool(() =>
    perform(r, r.ops.read(socket, buf))
  )
  Async.group:
    val pending = Future:
      perform(r, r.ops.read(socket, buf))
    buf.clear() // rejected: until group ends
    pending.await
  buf.clear() // allowed: group ended
}

onWorkerPool runs callback on a separate thread pool, rejecting thread-local state.

Error: body captures ThreadLocal reactor r.
(onWorkerPool is not the reactor thread.)

Error: buf is unavailable to the parent until the group ends, even after pending.await.

Group exit restores the parent's access to buf.

Future Vision for language safety: statically enforce where tasks run and what resources they may retain.

Error handling & Validation

Proposal: Result for error handling in direct style

GitHublampepfl/steps

Incubator for the Result[T, E] type.

Result · success or a typed error

enum Result[+T, +E]:
  case Ok[+T](value: T)
    extends Result[T, Nothing]
  case Err[+E](error: E)
    extends Result[Nothing, E]

compose dependent values

def parseInt(s: String): Result[Int, Err] = …
val (a, b) = ("123", "456")
Result:
  parseInt(a).ok + parseInt(b).ok

.ok unwraps or breaks out of Result: with Err.

SIP-84 Under review evolves this to an unboxed representation

Result[T, E] is now T ? E

Result: is now maybe:

r.ok is now r?

Proposal: Validation in direct style

GitHublampepfl/steps

Create an account

Stage 1 · all fields present, now parse and check

Name

Ada

✓ Name is present

Age

sixteen

✕ Enter a whole number

Email

ada@

✕ Enter a valid email

Stage 2 · checks that need a valid field

↓ No parsed age

18 or older?
Skipped until parsing succeeds

Collect errors across fields. Stop only the checks that depend on a failed value.

Validation Stage 0: test non-empty

GitHublampepfl/steps

Account form · all required fields must be present

import steps.result.Validation
import Validation.{scope, Validated}

case class Form(
  name: String, age: String, email: String)

def checkFields(f: Form): Validated[Unit, String] =
  Validation.validated:
    scope.test(f.name.nonEmpty, "Name: required")
    scope.test(f.age.nonEmpty, "Age: required")
    scope.test(f.email.nonEmpty, "Email: required")

Validated[A, E] =:=
Result[A, List[E]]

Open a validation scope.
scope value accumulates errors.

Simple Boolean Tests
no early abort, failures are accumulated.

checkFields(Form("", "", "")) → three required-field errors, together.

Validation Stages 1 & 2: parse and then transform

GitHublampepfl/steps

case class Account(name: String, age: Int, email: Email)
def validateForm(f: Form): Validated[Account, String] =
  Validation.validated:
    // stage 0
    scope.testAll(checkFields(f)).ok
    // stage 1
    val n = scope.test(parseName(f.name))
    val a = scope.test(parseInt(f.age))
    val e = scope.test(parseEmail(f.email))
    // stage 2
    val age = a.ok
    scope.require(age >= 18, "Age: must be 18+")
    Account(n.ok, age, e.ok)

"sixteen" → parse error · "16" → age rule error · "21" → success

Stage 0: merge errors: add to scope

Stage 0: .ok breaks early on stage 0 error.

State 1: Parse values with Result
each test may record an error in the scope, returning a handle on the value.

Stage 2: extract age
.ok breaks early if a had an error.

Stage 2: Validate age

Stage 2: break early with require

Stage 2: Extract remaining Result
don't force before checking age.

Proposal: Validation scope prevents shared mutation.

GitHublampepfl/steps

Batch signup · flag a duplicate email on both forms

def checkEmailConflict(a: Form, b: Form)(
    aErrors: Validation[String]^,
    bErrors: Validation[String]^
): Unit =
  val dupMsg = "Email: duplicated in batch"
  val distinct = a.email != b.email
  aErrors.test(distinct, dupMsg)
  bErrors.test(distinct, dupMsg)

Rejected · pass the same collector twice

Validation.validated[Unit, String] {
  checkEmailConflict(a, b)(scope, scope)
}

Validation is stateful and exclusive

class Validation[E]
  extends Stateful,
    ExclusiveCapability:
  // implementation omitted

^ grants exclusive write capability.
One writer for each form's error list.

Error: Separation Failure
Arguments aErrors and bErrors both capture scope.

A duplicate email gives each form its own error. Two write parameters cannot alias.

Parsing command-line
args

Proposal: Flagged command-liner parser generator

GitHubbishabosha/flagged

Combining best qualities of case-app and mainargs

Fastest at compiletime and runtime.

  • subcommand nesting to any depth
  • All parsing styles supported (even from scopt, scallop)
  • Automatic help text
  • derivation via idiomatic metaprogramming features.

Flagged: migration from @main

GitHubbishabosha/flagged

Before

@main def serve(
  host: String, port: Int = 8080, files: String*
): Unit = ...

After

@cmd def serve(
  host: String, port: Int = 8080, files: String*
): Unit = ...

@main def run(args: String*): Unit =
  Flagged.parseOrExit[this.type](args)
./serve.scala localhost 7777 foo.scala

unchanged except for @main → @cmd.

foo.type => search for @cmd methods.

default behavior same as @main:
positional parameters

Flagged: type class derivation also

GitHubbishabosha/flagged

Before

@cmd def serve(
  host: String, port: Int = 8080, files: String*
): Unit = ...

After

case class Serve(
  host: String, port: Int = 8080, files: String*
) derives Parser.Command

@main def run(args: String*): Unit =
  val serve = Flagged.parseOrExit[Serve](args)
  ...
./serve.scala localhost 7777 foo.scala

typical type-class derivation flow.

class type => look for Parser[T].

still same default behavior

Flagged: evolve to named arguments

GitHubbishabosha/flagged

Previously · positional arguments

./serve.scala localhost 7777 foo.scala

Add annotations to the same method

case class Serve(
  @opt host: String,
  @opt(short = 'p') port: Int = 8080,
  files: String*
) derives Parser.Command

Now · named options, with the same values

./serve.scala -p 7777 --host localhost foo.scala
./serve.scala --host localhost foo.scala

@opt makes a parameter named.
port accepts --port or -p.

Omit --port to use default arg.

Unannotated files stays positional.

Named parameters in any order!

Flagged: Nested subcommands

GitHubbishabosha/flagged

Git-style CLI · two levels of subcommands

enum Gitto derives Parser.CommandGroup:
  case Clone(repo: String,
    @opt(short = 'd') depth: Option[Int] = None)
  case Remote(action: RemoteAction)
  case Status(@opt(short = 's') short: Boolean = false)

enum RemoteAction derives Parser.CommandGroup:
  case Add(name: String, url: String)
  case Remove(name: String)

@main def gitto(args: String*): Unit =
  val cfg = Flagged.parseOrExit[Gitto](args)
  ...
./gitto.scala remote add origin \
  https://github.com/lampepfl/gears.git

uses type-class derivation

Level 1: remote selects a nested command group.

Level 2: add selects a command within that group; name and url are positional arguments.

One parse returns a nested value:
Gitto.Remote(
  RemoteAction.Add(...))

Proposal: Mirrors for derivation · annotations, default-args, methods

GitHubbishabosha/flagged

Annotations · option names, flags, help

trait AnnotMirror[T]:
  type MirroredSelfAnnotations <: Tuple
  type MirroredAnnotations <: Tuple

Defaults · values for omitted arguments

trait Defaults[A]:
  def hasDefault(index: Int): Boolean
  def defaultArgument(index: Int): Any

Methods · parameter structure and invocation

trait MethodMirror[T]
    extends Defaults[Any]:
  type MirroredElemLabels <: Tuple
  type MirroredElemTypes <: Tuple
  type MirroredResult

  def invoke(args: Array[Any]): Any

Metadata in types · defaults and calls in methods

Proposal: compiler generated Mirrors

GitHubbishabosha/flagged

Flagged · generation entry points

transparent inline given ofProduct[T]:
    AnnotMirror.Product[T] =
  ${ macros.AnnotationMacros.annotMirrorProduct[T] }

inline def derived[A]: Defaults[A] =
  ${ macros.DefaultMacros.defaults[A] }

transparent inline given of[T]: MethodsMirror.Of[T] =
  ${ macros.MethodMacros.methodsMirror[T] }

Today: macros discover the source metadata and generate witnesses.

Proposal: the compiler synthesizes these witnesses, as it does for scala.deriving.Mirror.

Library authors consume a shared interface through ordinary inline derivation.

An annotation Mirror for supplied arguments

GitHubbishabosha/flagged

Annotation occurrence

case class Greet(
  @opt(short='w') who: String)
case class opt(
  name: String = "",
  help: String = "",
  short: Char = '\u0000',
  // ... more parameters
) extends StaticAnnotation derives Defaults

Annotation Mirror reflects explicit literal arguments:

ArgumentList[opt,
  "short" *: EmptyTuple, 'w' *: EmptyTuple,
  2 *: EmptyTuple]

Only short is supplied.
how do we materialise the other params?

name and help use their default.

short is the third parameter (index 2).

Defaults mirror provides arguments.

Capture the supplied argument label and literal value.

short is the third parameter (index 2).
Defaults mirror provides the rest.

Idiomatic inline derivation @opt(short='w')

GitHubbishabosha/flagged

Annots.fieldAnnotsOfSome · non-empty arguments, abridged

inline erasedValue[m.MirroredAnnotations] match
  case _: (ArgumentList[opt,
      ns, vs, ?] *: ?) =>
    collectField[ns, vs](
      name = "", short = MaybeChar.empty, help = "",
      positional = false, …)

Annots.collectField · selected cases

inline erasedValue[(Ns, Vs)] match
  case _: ("short" *: nt, v *: vt) =>
    collectField[nt, vt](
      name, MaybeChar(constValue[v & Char]),
      help, positional, hidden,
      group, aliases, split, greedy)
  case _: (EmptyTuple, ?) =>
    FieldAnnots(name, short, help, positional,
      hidden, group, aliases, split, greedy)

Find @opt in this field's annotations.

Only need the explicit named arguments

traverse the explicit arguments.

shortcut: prefill default arguments

consume short = 'w' argument from head

recursive call on tail types

materialize 'w' from its singleton type.

After explicit arguments are consumed, build one record. Defaults provided in initial call.

Derivation: fast to compile, too

GitHubbishabosha/flagged

Compile one generated CLI source · milliseconds · warm compiler · lower is better

CLI shapeNo parserFlaggedmainargscase-app
10 mixed fields53.6 ± 4.8100.9 ± 8.6241.1 ± 25.1460.7 ± 34.4
25 defaulted fields47.3 ± 2.4123.2 ± 16.3246.3 ± 19.0502.5 ± 42.5
3 subcommands48.6 ± 4.5108.6 ± 7.1264.9 ± 21.5467.2 ± 65.3
3 command methods29.6 ± 1.4113.6 ± 9.7254.5 ± 43.4497.3 ± 144.2
Docker-style CLI58.1 ± 5.8164.3 ± 10.5213.0 ± 25.7520.5 ± 68.6

47–106 ms added over the no-parser baseline · lowest compile time of the three libraries in every scenario.

Flagged: Lowest Latency and Allocations

GitHubbishabosha/flagged

Flagged · construct a parser for a Docker-CLI clone and parse the args.

ParserTime (µs/op)Allocation (B/op)
Flagged2.247 ± 0.01613,720
mainargs11.482 ± 0.20341,614
scopt49.027 ± 0.624107,901
case-app51.935 ± 2.442369,246
scallop141.698 ± 0.598445,675
picocli232.840 ± 2.293396,725

5.1× faster than the next parser in this workload.

Realising the
potential of Generic Data

Tuple element types with a common upper-bound

GitHubbishabosha / NamedTupleUtil.scala

Check the numeric age, then render the whole row

val row = (Text("Jamie"), Number(42))
require(row(1).value >= 18) // precise per-elem type

render(row)
// Jamie,42
trait Cell { def text: String }

def render[T <: Tuple: ElemsOf[Cell]](cells: T) =
  elemsIterator[T, Cell](cells)
    .map(_.text).mkString(",")
type ElemsOf[I] =
  [T <: Tuple] =>> Tuple.Union[T] <:< I

Keep the type at each position. row(1) is a Number; its .value is an Int. A Seq[Cell] loses that precise type.

Iterate without casts in the renderer. With this evidence, elemsIterator returns an Iterator[Cell], so each element has .text.

A heterogeneous tuple. Text | Number is the union of its element types. Both must be below Cell.

Named Tuples with common field labels

GitHubbishabosha / NamedTupleUtil.scala

Cells in the row; strings in its matching headings

type PersonRow = (name: Text, age: Number)

val row =
  (name = Text("Jamie"), age = Number(42))
val titles =
  (name = "Full name", age = "Age (years)")

report[PersonRow](row)(titles)
Full name: Jamie
Age (years): 42

Merge two separate sources with different field types, but matching labels and order.

Named Tuples with common field labels

GitHubbishabosha / NamedTupleUtil.scala

The report takes cells and a matching record of headings

def report[
  Row <: AnyNamedTuple: FieldsOf[Cell]
](row: Row)[
  Titles <: SameFields[Row]
            : FieldsOf[String]
](titles: Titles): String =
  fieldsIterator[Titles, String](titles)
    .zip(fieldsIterator[Row, Cell](row)) …
type FieldsOf[I] = [F <: AnyNamedTuple] =>>
  ElemsOf[I][NamedTuple.DropNames[F]]

type SameFields[F <: AnyNamedTuple] =
  NamedTuple.Map[F, [_] =>> Any]

Prove the input bound. DropNames exposes the value tuple, so every field must implement Cell.

Preserve labels and order. Forget the original value types; keep the record's fields.

Transform cells into heading strings. Independently prove that every supplied heading is a String.

Callback: structural types

GitHubbishabosha/tiger-site-gen

GitHubbishabosha/simple-invoicing

Named tuples for nested data

Why can't NamedTuples be as useful as TypeScript's records?

// Invoice.scala
val config = (
  invoice = (
    id = 5,
    period = (start = "2025/1/27", days = 30)
  ),
  listings = (
    items = Vector(
      (desc = "Premium Insurance (1 year)",
        qty = 2, price = 250),
      (desc = "Samsung Galaxy S13",
        qty = 1, price = 999)
    ),
    taxRate = 12
…

Parsing values from text
How do we extract a date and calculate when payment is due?

Deep nesting
I want to talk about the items, without explicitly redeclaring their type.

Selecting types inside a nested record

GitHubbishabosha/simple-invoicing

Declare a single structure for the deeply nested data

type Invoice = (
  invoice: (
    id: Int,
    period: (start: String, days: Int)
  ),
  listings: (
    items: Vector[
      (desc: String, qty: Double, price: Int)
    ],
    taxRate: Int,
  )
…

1 · Select the record

type Listings =
  SelectField[Invoice, "listings"]

2 · Select its collection

type Items =
  SelectField[Listings, "items"]

3 · Select the element type

type Item = SelectElem[Items]

type Item stays in sync with type Invoice

Invoice → Listings → Items → Item

Invoice SelectField[Invoice, "listings"] SelectField[Listings, "items"] SelectElem[Items]

Proposal: match types for NamedTuple path traversal

GitHubbishabosha/simple-invoicing

match-types → algebraic operations for nested data

type SelectField[NT <: AnyNamedTuple, K <: String] =
  NT match
    case NamedTuple[ns, vs] => Search[K, ns, vs]

type SelectElem[S <: Seq[?]] = S match
  case Seq[e] => e

type Search[K <: String, Ns <: Tuple, Vs <: Tuple] =
  (Ns, Vs) match
    case (K *: _, v *: _)   => v
    case (_ *: ns, _ *: vs) => Search[K, ns, vs]

Find the field
Walk names and value types together.

Then unwrap its container
The output of one operation feeds the next.

Effective Match Types video thumbnail Jamie Thompson: Effective Match Types [Scala Days 2025]

Parsing useful data

GitHubbishabosha / 1 - On Configuration and Named Tuples.md

// Invoice.scala
val config = (
  invoice = (
    id = 5,
    period = (start = "2025/1/27", days = 30)
…

Prototype apply functions directly within deeply nested data

val parser = ParseFrom.identity[Invoice]
  .patch(
    Patch(_.invoice.period.start)
      .to[LocalDate]
  )
val application = parser.tryRead(config)

Data provides only text
The invoice needs a date to calculate when payment is due.

Select where change occurs
apply a function at this path.

String → LocalDate
Parse yyyy/M/d; report an invalid date.

Parsing useful data

GitHubbishabosha / 1 - On Configuration and Named Tuples.md

Prototype apply functions directly within deeply nested data

val parser = ParseFrom.identity[Invoice]
  .patch(
    Patch(_.invoice.period.start)
      .to[LocalDate]
  )
given ParseFrom[String, LocalDate]:
  def tryRead(in: String)
    : Validated[LocalDate, Err] =
  Validation.validate(…)

Select this nested leaf
Keep days and the rest of the invoice.

String → LocalDate
Parse yyyy/M/d; report an invalid date.

Propagate errors
each patch can have independent errors.

Scala Object Notation: Declare the structure you need

GitHubbishabosha/scala-object-notation

type Invoice = (
  invoice: (
    id: PosInt,
    period: (start: LocalDate, days: PosInt)
  ),
  listings: (
    items: Vector[
      (desc: String, qty: BigDecimal, price: Int)
    ],
    taxRate: Range[0, 100],
  )
…
given ReadWriter[PosInt] = …
given ReadWriter[LocalDate] = …
given [A, B] => ReadWriter[Range[A, B]] = …
val text = os.read(
  os.pwd/"Invoice.scala"
) // = "val config = (…)"

given ReadWriter[Invoice] = …

scalanotation.Readers
  .readDeclsAs[Invoice](text)

Semantic types: No more Int, String.

Validate and transform
Int → PosInt or Range[0, 100];
String → LocalDate
Parse basic values into semantic data, or reject with a error message.

Scala Object Notation: mapped schemas

GitHubbishabosha/scala-object-notation

def parseDate(s: String):
  Result[LocalDate, DecodeError] = …

given ReadWriter[LocalDate] =
  summon[ReadWriter[String]]
    .bimapResult(parseDate)(_.toString)

Reified Function
How to tear-down/construct type A from an abstract structure.

Mapped Schema
A LocalDate is structurally "a String", but adds validation

Scala Object Notation: a bad date

GitHubbishabosha/scala-object-notation

Invoice.scala · excerpt

val config = (
  invoice = (
    id = 5,
    period = (start = "2025-1-27",
      days = 30)
  ),
  // listings unchanged
)

Read using the mapped LocalDate schema

Readers.readDeclAs[Invoice](
  text, rootName = "config"
)
4:15: In path '.invoice.period.start':
Text '2025-1-27' could not be parsed
at index 4

The full decoding error: source line and column, nested field path, and the date parser's explanation.

The mapped schema expects yyyy/M/d.
Write "2025/1/27" instead.

Scala Object Notation: a missing codec

GitHubbishabosha/scala-object-notation

A custom type deep inside the invoice schema

final class Money(val cents: Int)

type Invoice = (
  listings: (
    items: Vector[(desc: String,
      qty: BigDecimal, price: Money)],
    taxRate: Int
  )
)

given ReadWriter[Invoice] =
  ReadWriter.ofFields[Invoice]
at path '.listings.items[].price':
Could not find ReadWriter[Money].

Derivation points to the missing leaf.
[] means an element of the collection.

Supply the leaf codec, then derive again

given ReadWriter[Money] =
  summon[ReadWriter[Int]]
    .bimap(new Money(_))(_.cents)

RawSchema: structure + operations

GitHubbishabosha/scala-object-notation

Scala value

case class Person(...)

(x: Int, y: Int)

Vector[Person]

Map[String, String]

ReadWriter[A]

.schema: RawSchema[A]

Cursors
take values apart

Builders
put values together

Structure

Records → fields

Tuples → elements

Collections → elements

Primitives → values

Interpreters use the structure and these operations to work with your values.

Scala Object Notation: schemas compose

GitHubbishabosha/scala-object-notation

ReadWriter[InvoiceSchema]

invoice · named tuple

id
PosInt mapped from Int

period · named tuple

start
LocalDate mapped from String

days
PosInt mapped from Int

listings · named tuple

items · Vector

Element schema · named tuple

desc
String

qty
BigDecimal

price
Int

taxRate
Range[0, 100] mapped from Int

contextual derivation composes leaf schemas automatically.

One declaration, many interpreters

GitHubbishabosha/scala-object-notation

Declare once

ReadWriter[InvoiceSchema]

RawSchema: structure + cursors + builders

JSON

Read / write

YAML

Read / write

SCON

Read / write

Debug logging

Traverse / inspect

…or another interpreter of the same schema.

Declare your data once. Each interpreter decides what to do with it.

Universal Schema: router

GitHubbishabosha/scala-object-notation

data with no known schema, e.g. raw JSON, YAML, etc.

RawSchema.Router[A] · self

Scalars

string · number · bool · null

Record / dictionary

String → self

Sequence

elements: self

Children reuse the same schema

interopable with upickle, scala-yaml, etc.

Proposal: Install default values within Structural types.

GitHubbishabosha/scala-object-notation

Sample config data structure, we want to supply defaults dynamically.

type Config = (
  db: Option[(
    host: String,
    port: Int,
    replicas: Vector[(host: String, port: Int)]
  )]
)
given DefaultValues[Config] = DefaultValues.of { c =>
  Seq(
    c.db := Some(("120.0.0.1", 5432, Vector())),
    c.db.some.port := 5432,
    c.db.some.replicas.each.port := 5432
  )
}

Step into optional/sequence types.
e.g. they provided a host but no port.

Proposal: Selectable paths that work with Option/Vector.

GitHubbishabosha/scala-object-notation

class Path[T, AtField <: Boolean] extends Selectable:
  type Fields = NamedTuple.Map[
    NamedTuple.From[T],
    [F] =>> Path[F, true]
  ]
  def selectDynamic(name: String): Path[?, true] = ...
extension [T](path: Path[T, ?])
  def some(using r: OptionLike[T]): Path[r.Inner, false] = ...
  def each(using r: VectorLike[T]): Path[r.Inner, false] = ...

extension [T](path: Path[T, true])
  def :=(value: T): Binding = ...

1 · Project fields into Path
select a field of T to extend the Path.

DefaultValues.of: c =>
  c.db // c.selectDynamic("db")

2 · Unwrap a container

DefaultValues.of: c =>
  c.db.some.replicas.each

3 · Install a binding only at a field

DefaultValues.of: c =>
  c.db.some.port := 5432

No need for complex match-types to handle field selection.

SCON decoding: allocation and throughput

GitHubbishabosha/scala-object-notation

Decode 100 order records from strings. SCON uses SCON text; the others use JSON.

DecoderTime (µs/op)Allocation (B/op)
jsoniter-scala9.594 ± 0.13616,344
zio-blocks12.647 ± 0.06216,360
SCON, batched + typed17.294 ± 0.0629,696
uPickle46.452 ± 0.903113,904

Less allocation here; still slower than jsoniter-scala and zio-blocks.

Proposal: Result.task for typed side-effects

GitHublampepfl/steps

SCON as a Result.task testbed.

def decodeField(): Result[Unit, DecodeError] =
  Result.task:
    decodeInt().check
    slots.setInt(0, pullIntValue())
object Result:
  …
  val done: Result[Unit, Nothing] = Ok(())

Unit returning block

decodeInt() pushes int to slot.
.check breaks to task: on failure.

Update the builder as a side effect.

successful task: block returns done

On happy path: no allocations for successful blocks.

Bonus: even more Named Tuples!

Record-prefix conformance

GitHubbishabosha/tiger-site-gen

class Record[T <: AnyNamedTuple](private val live: T)
    extends Selectable:
  type Fields = T
  inline def selectDynamic(name: String): Any =
    live.productElement(constValue[IndexOf[Name, Fields]])
type Parent = (id: String)
def idOf(meta: Record[Parent]): String = meta.id
val parent = Record((id = "record-slide"))
val slide = parent ++ (layout = "standard")

idOf(parentView(slide)) // "record-slide"
slide.layout            // "standard"
def parentView[T <: AnyNamedTuple](r: Record[T])(
  using Record.IsSubPrefix[T, Parent]
): Record[Parent] = r.asInstanceOf[Record[Parent]]

Named tuple fields, record interface.
Fields = T gives typed selections such as meta.id.

Access fields by index (shared prefix fields have same offsets)

slide appends (layout: String).
Its prefix is still the fields of parent.

Downcast slide to Parent safely

Proof Parent's fields are the prefix of T's

Use case: extensible markdown slide generator.

GitHubbishabosha/tiger-site-gen

TemplateFunctions.scala

class TemplateFunctions[T <: AnyNamedTuple](
  private val functions: Record[T]
) extends Selectable:
  type Fields = T

TalkRevealTheme.apply · layout and page settings abbreviated

object TalkRevealTheme:
  def apply(fonts: DeckFonts = DeckFonts()) =
    RevealTheme.withTemplates(
      RevealTheme.defaultTemplates ++
        WhiteboardTemplates.templates,
      …
    )

Templates are a record of functions. TemplateFunctions[T] wraps Record[T] and exposes its named fields through Selectable.

Extend the record; pass it to the parent. Customise markdown with extra functions.

The parent requires its original fields as a prefix.

Powering Presentation Generation

GitHubbishabosha/tiger-site-gen

RevealTheme · structural types to the max

case class SlideMeta(
  id: String, seconds: Int = 5, layout: String
)

class RevealTheme(...) extends Theme:
  ...
  type SiteMap = (
    deck: Directory[(
      index: Doc[DeckMeta],
      `speaker-notes`: Doc[NotesMeta],
      slides: Docs[SlideMeta]
    )]
  )
  • SiteMap: nested structural type.
  • 1:1 with directory layout
  • centralised access to metadata across-pages.

Each document exports Typed frontmatter

Yes, even this presentation!

GitHubbishabosha/tiger-site-gen

---scala
(id = "son-presentation-frontmatter", layout = "standard")
---

## Yes, even this presentation!

Frontmatter: metadata exported to templates

Next: Laminar Interactive Lab

have fun with DataFrame · reactive notebook · widgets

Reactive Notebook built on Laminar.

Interactive notebook

Questions

Jamie Thompson · @bishabosha

With thanks to

Hamza Remmal, Solal Pirelli, Nguyen Pham,
Rikito Taniguchi, Seth Tisue, Tomasz Godzik,
Bill Venners, Chee Seng Chua,
Scala Core, reviewers and community contributors

Appendices 🏛️

The September idea map

September 2026 mindmap of Scala library and Toolkit ideas

Results: Oct 2026

Working with Generic Data

  • Tuple
  • Named Tuples

Universal Schema

  • Fast serialisation (JSON/YAML/etc)
  • Debugging/ToString
  • Configuration

Ideas

"Direct Style"

  • boundary/break abstractions
  • Async I/O
  • Error handling & Validation

Metaprogramming

  • New Mirror types
  • scala-reflect ported for Scala 3

Toolkit

  • CLI Arguments & Configuration
  • Serialisation
  • Parallel collections V2.0

Kqueue: poll, then perform I/O

GitHublampepfl/gears

RPC: write request to server, wait for response

Scala Native · macOS, FreeBSD targets only

registerWrite(kq, socket)
var running = true
while running do
  val n = pollEventsForever(kq, events, capacity)
  for i <- 0 until n if running do
    val event = events(i)
    if isWriteEvent(event) then
      nioWriteBytes(socket, buf)
      if !buf.hasRemaining() then switchToRead(kq, socket)
    else if isReadEvent(event) then
      buf.clear()
      val bytesRead = nioReadBytes(socket, buf)
      running = bytesRead != EOF
      if bytesRead > 0 then
        buf.flip()
        drainTo(buf, response)

register WRITE interest

Loop forever

BLOCK in kevent until sockets are ready. Return a batch of events.

Loop through the batch of events.

check for WRITE events

do WRITE I/O

drop WRITE, register READ interest

check for READ events

do READ I/O

drain each chunk to the response.

What happens to this input?

GitHubbishabosha/scala-object-notation

SCON text

(db = (port = 5432, host = "db"),
 weights = Vector(2, 1))

Application types

case class Endpoint(port: Int, host: String)
case class Config(
  db: Option[Endpoint], weights: Array[Int]
)

Primitive and reference fields
inside a nested record.

The schema chooses the result:
an optional object and an integer array.

The cursor reads a typed value

GitHubbishabosha/scala-object-notation

(db = (port = 5432, host = "db"), weights = Vector(2, 1))
Decoder state
BuilderSlots (pooled)
Borrowed for Endpoint
Int—
Ref—
portprims[0]—
hostrefs[0]—

Borrow BuilderSlots for Endpoint. The cursor reaches the next Int.

Decoder state
BuilderSlots (pooled)
Borrowed for Endpoint
Int5432
Ref—
portprims[0]—
hostrefs[0]—

Read the raw number into the Int cell.

Decoder state
BuilderSlots (pooled)
Borrowed for Endpoint
Int5432
Ref—
portprims[0]5432
hostrefs[0]—

Copy the raw Int into BuilderSlots at prims[0], without boxing.

The cursor moves to the String

GitHubbishabosha/scala-object-notation

(db = (port = 5432, host = "db"), weights = Vector(2, 1))
Decoder state
BuilderSlots (pooled)
Borrowed for Endpoint
Int5432
Ref—
portprims[0]5432
hostrefs[0]—

The cursor advances to the String.

Decoder state
BuilderSlots (pooled)
Borrowed for Endpoint
Int5432
Ref@host
portprims[0]5432
hostrefs[0]—
Referenced object
@host"db"

Copy a reference '@host' into the Ref cell, a reference to the parsed string object.

Decoder state
BuilderSlots (pooled)
Borrowed for Endpoint
Int5432
Ref@host
portprims[0]5432
hostrefs[0]@host
Referenced object
@host"db"

Copy @host into the borrowed BuilderSlots at refs[0]. Both slots refer to the same String.

The constructor pulls from BuilderSlots

GitHubbishabosha/scala-object-notation

(db = (port = 5432, host = "db"), weights = Vector(2, 1))
Decoder state
BuilderSlots (pooled)
Borrowed for Endpoint
Int5432
Ref@host
portprims[0]5432
hostrefs[0]@host
TypedFactory.OfProduct[Endpoint].fromSlots · constructor argumentsEndpoint(port = slots.getInt(0), host = slots.getRef(1))

The record is complete. Its borrowed BuilderSlots holds both fields.

Decoder state
BuilderSlots (pooled)
Borrowed for Endpoint
Int5432
Ref@host
portprims[0]5432
hostrefs[0]@host
TypedFactory.OfProduct[Endpoint].fromSlots · constructor argumentsEndpoint(port = 5432, host = slots.getRef(1))

getInt(0) pulls port directly into the constructor argument, without boxing.

Decoder state
BuilderSlots (pooled)
Borrowed for Endpoint
Int5432
Ref@host
portprims[0]5432
hostrefs[0]@host
TypedFactory.OfProduct[Endpoint].fromSlots · constructor argumentsEndpoint(port = 5432, host = @host)

getRef(1) pulls the same String reference for host.

Decoder state
BuilderSlots (pooled)
Borrowed for Endpoint
Int5432
Ref@host
portprims[0]5432
hostrefs[0]@host
Constructed object
@endpointEndpoint(port = 5432, host = @host)

The factory invokes Endpoint with those two arguments.

Decoder state
BuilderSlots (pooled)
Borrowed for Endpoint
Int5432
Ref@endpoint
portprims[0]5432
hostrefs[0]@host
Referenced object
@endpointEndpoint(port = 5432, host = @host)

pushRef publishes the new Endpoint reference in the decoder’s Ref slot.

Decoder state
BuilderSlots (pooled)
Returned to pool · reusable
Int5432
Ref@endpoint
portprims[0]5432
hostrefs[0]@host
Referenced object
@endpointEndpoint(port = 5432, host = @host)

Release BuilderSlots to the pool. Endpoint keeps its values, not the buffer.

The child becomes a parent field

GitHubbishabosha/scala-object-notation

(db = (port = 5432, host = "db"), weights = Vector(2, 1))
Decoder state
BuilderSlots (pooled)
Borrowed for Config
Int5432
Ref@endpoint
dbrefs[0]—
weightsrefs[1]—
Referenced object
@endpointEndpoint(port = 5432, host = @host)

Config.db field expects Option[Endpoint]. So we need to wrap @endpoint.

Decoder state
BuilderSlots (pooled)
Borrowed for Config
Int5432
Ref@database
dbrefs[0]—
weightsrefs[1]—
Referenced object
@databaseSome(@endpoint)

@database wraps the existing @endpoint reference in Some(...), stored in the Ref cell.

Decoder state
BuilderSlots (pooled)
Borrowed for Config
Int5432
Ref@database
dbrefs[0]@database
weightsrefs[1]—
Referenced object
@databaseSome(@endpoint)

Push the @database reference to the BuilderSlots for Config at refs[0].

The same slot feeds a typed array

GitHubbishabosha/scala-object-notation

(db = (port = 5432, host = "db"), weights = Vector(2, 1))
Decoder state
Array[Int] builder
Int2
Ref@database
0—
1—

Moving on to weights... Read the first raw element into the same Int cell.

Decoder state
Array[Int] builder
Int2
Ref@database
02
1—

Append the Int cell's value to the typed array builder.

Decoder state
Array[Int] builder
Int1
Ref@database
02
1—

The cursor advances. Overwrite the Int cell with 1.

Decoder state
Array[Int] builder
Int1
Ref@database
02
11

Append Int cell again. Both array elements are raw Int values.

Decoder state
Array[Int] builder
Int1
Ref@weights
02
11
built@weights
Referenced object
@weightsArray[Int](2, 1)

Finish the array. The Ref cell now points to the array object.

Config pulls from its own BuilderSlots

GitHubbishabosha/scala-object-notation

(db = (port = 5432, host = "db"), weights = Vector(2, 1))
Decoder state
BuilderSlots (pooled)
Borrowed for Config
Int1
Ref@weights
dbrefs[0]@database
weightsrefs[1]—
Referenced object
@weightsArray[Int](2, 1)

The completed array is ready for the Config builder.

Decoder state
BuilderSlots (pooled)
Borrowed for Config
Int1
Ref@weights
dbrefs[0]@database
weightsrefs[1]@weights
Referenced object
@weightsArray[Int](2, 1)

Copy @weights into the Config's borrowed BuilderSlots at refs[1].

Decoder state
BuilderSlots (pooled)
Borrowed for Config
Int1
Ref@weights
dbrefs[0]@database
weightsrefs[1]@weights
TypedFactory.OfProduct[Config].fromSlots · constructor argumentsConfig(db = slots.getRef(0), weights = slots.getRef(1))

The Config factory is ready to pull both arguments from BuilderSlots.

Decoder state
BuilderSlots (pooled)
Borrowed for Config
Int1
Ref@weights
dbrefs[0]@database
weightsrefs[1]@weights
TypedFactory.OfProduct[Config].fromSlots · constructor argumentsConfig(db = @database, weights = slots.getRef(1))

getRef(0) pulls the completed Option reference for db.

Decoder state
BuilderSlots (pooled)
Borrowed for Config
Int1
Ref@weights
dbrefs[0]@database
weightsrefs[1]@weights
TypedFactory.OfProduct[Config].fromSlots · constructor argumentsConfig(db = @database, weights = @weights)

getRef(1) pulls the completed array reference for weights.

Decoder state
BuilderSlots (pooled)
Borrowed for Config
Int1
Ref@weights
dbrefs[0]@database
weightsrefs[1]@weights
Constructed object
@configConfig(db = @database, weights = @weights)

Construct Config from those references; no child objects are copied.

Decoder state
BuilderSlots (pooled)
Borrowed for Config
Int1
Ref@config
dbrefs[0]@database
weightsrefs[1]@weights
Referenced object
@configConfig(db = @database, weights = @weights)

pushRef places the completed Config reference in the decoder’s Ref slot.

Decoder state
BuilderSlots (pooled)
Returned to pool · reusable
Int1
Ref@config
dbrefs[0]@database
weightsrefs[1]@weights
Referenced object
@configConfig(db = @database, weights = @weights)

Return the parent’s BuilderSlots to the pool. Config is independent of it.

The completed Config becomes a Result

GitHubbishabosha/scala-object-notation

(db = (port = 5432, host = "db"), weights = Vector(2, 1))
Decoder state
Public result
Int1
Ref@config
Result[Config, DecodeError]
Result.Ok(@config)
Referenced object
@configConfig(db = @database, weights = @weights)

One Ok wrapper at the public boundary, containing the existing @config.