Dan Billings

Texts in Typed Systems

20years

Typed all the way down

Commutative triangle: liftF from the coproduct G into Free of G, the interpreter eta from G to IO, and foldMap of eta from Free of G to IO. G IO Free[G, ·] η liftF foldMap(η)
The shape behind every library here. Programs are written against G, a coproduct of small algebras. One interpreter η for G, assembled from smaller ones, and foldMap(η) runs them.
Dan Billings Twenty years building production systems on the JVM, from genomics at scale to the libraries an AI agent needs.

I write Scala 3: free monads, refined types, and compile-time derivation, so the compiler catches the mistakes a reviewer would miss.

These are the libraries I've extracted from my own work and published. Each one keeps its effects behind a small typed interface, so you can test it without a network, and Iron constraints on the types reject bad values at the boundary.

Libraries

Library (iron-mcp).

A Model Context Protocol server for Scala 3, written from the 2026-07-28 specification. It has no Java SDK, no Jackson and no reflection, and it cross-builds to the JVM and Scala Native.

type Repeats = Int :| (
  Interval.Closed[1, 10] DescribedAs "How many times")

final case class Greet(name: Recipient, times: Repeats)
    derives Decoder, JsonSchemaOf

The tool's JSON Schema is derived from the types at compile time. Interval.Closed[1, 10] becomes minimum and maximum, so the schema and the validation can't drift apart.

"io.github.danbills" %% "iron-mcp-core" % "0.1.0"

Library (scala-openai-client).

A client for any OpenAI-compatible server (OpenAI, llama.cpp, vLLM) written as Free algebras: chat, SSE streaming, tool calls, speech and transcription. Smart constructors inject into any EitherK coproduct, so a program mixes them with its own algebras.

def streamToConsole[G[_]](req: ChatCompletionRequest)(
    using S: ChatStreaming[G], Out: Console[G])
    : Free[G, ChatMessage] =
  S.onDeltas(req): d =>
    d.content.fold(Free.pure[G, Unit](()))(Out.print)
  .map(_.message)

Every token goes to the console as it arrives, then the program continues once with the assembled reply. Streaming ops are interpreted into fs2.Stream, so the rest of the program runs per event.

Contribution (Iron: scodec support).

The iron-scodec module, merged upstream into Iron, the refined-types library for Scala 3. It gives binary codecs to refined types, so a decoder rejects bytes that violate the constraint.

val codec = Codec[Int :| Positive]

// fails: -5 is not Positive
codec.decode(int32.encode(-5).require)

"io.github.iltotore" %% "iron-scodec" % "3.3.2"

Tool (claude-status).

A status line for Claude Code, compiled to a GraalVM native image so it starts fast enough to run on every prompt. It decodes the session JSON into Iron-refined types and renders bar, compact, emoji or git layouts.

type Percentage = Double :| Interval.Closed[0.0, 100.0]
type NonNegInt  = Int :| GreaterEqual[0]

The pattern

Each library starts as small algebras: enums of operations, each indexed by the type it returns. Every algebra gets a capability class whose smart constructors inject its operations into any coproduct G that contains it. This is Rúnar Bjarnason's "reasonably priced monads" pattern, built on InjectK and EitherK.

A program asks only for the capabilities it uses. The one on the right records from the microphone, transcribes, asks a model and speaks the answer. That spans three algebras, and the program doesn't know how any of them run.

Effects happen only in interpreters, which are natural transformations into F, combined with .or. The same program runs against a local llama.cpp server, a hosted endpoint, or a pure test interpreter that records every request.

enum ChatOp[A]:
  case Complete(request: ChatCompletionRequest)
    extends ChatOp[ChatCompletionResponse]

final class Chat[G[_]](using InjectK[ChatOp, G]):
  def complete(request: ChatCompletionRequest)
      : Free[G, ChatCompletionResponse] =
    Free.liftInject[G](ChatOp.Complete(request))

def narrate[G[_]](using D: AudioDevice[G],
    A: Audio[G], C: Chat[G]): Free[G, Option[String]] =
  for
    take    <- D.record(RecordLimit.For(5.seconds))
    heard   <- A.transcribe(AudioTranscriptionRequest(take))
    replied <- C.ask(ChatCompletionRequest(model,
                 List(ChatMessage(Role.User, heard.text))))
    text     = replied.flatMap(textOf)
                 .flatMap(_.refineOption[Not[Empty]])
    _       <- text.fold(Free.pure[G, Unit](()))(t =>
                 A.speak(TextToSpeechRequest(input = t))
                   .flatMap(D.play))
  yield text

Writing