Dan Billings

Free monads as contracts

Every library I publish starts the same way. Before any HTTP client, codec or retry policy, I write down the operations as small Scala 3 enums. Each case is indexed by the type it returns. For chat completions, there is one operation:

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

A program built from these operations is a value. It describes the calls it wants to make but doesn't make them. Something has to run it, and that something is an interpreter: a natural transformation ChatOp ~> F.

The algebra is the part you review

Those three lines are the contract. They say what the library can do, which types go in, and which come out. A reviewer can hold the whole thing in their head. The implementation behind it may be a few thousand lines of HTTP, SSE parsing and error handling. That code still matters, but it can only ever produce what the algebra allows, because it has to typecheck as ChatOp ~> F.

Refined types make the contract tighter. When a temperature is Double :| (GreaterEqual[0.0] & LessEqual[2.0]), an out-of-range value fails where it is written or parsed. It never becomes a 400 response from a server three layers down.

Reasonably priced monads

One algebra is easy. Real programs need several: chat, transcription, speech, a microphone, a console. The pattern I use for that comes from RĂșnar Bjarnason's talk Composable application architecture with reasonably priced monads. Each algebra gets a capability class whose smart constructors inject its operations into any coproduct G that contains it:

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

object Chat:
  given [G[_]](using InjectK[ChatOp, G]): Chat[G] = new Chat[G]

A program then asks for exactly the capabilities it uses, and nothing else:

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

G is chosen at the edge, as a right-nested EitherK, and InjectK instances are derived along its spine:

type OpenAIOp[A] = EitherK[ChatOp, AudioOp, A]
type VoiceOp[A]  = EitherK[AudioDeviceOp, OpenAIStreamingOp, A]

A user of the library can add algebras of their own, such as a console or a UI, to the same coproduct, and the library's programs still run in it. The price is the one in the title: an InjectK per capability and a type alias per coproduct, which is reasonable.

Testing without a network

Interpreters combine with .or, one per algebra, in the same order as the coproduct. Because effects only happen there, a test can supply different ones. This one records every chat request in State and replies with canned text (fakeResponse builds a one-choice response):

type Recorded[A] = State[List[ChatCompletionRequest], A]

def recording(reply: String): ChatOp ~> Recorded =
  new (ChatOp ~> Recorded):
    def apply[A](op: ChatOp[A]): Recorded[A] = op match
      case ChatOp.Complete(req) =>
        State(log => (log :+ req, fakeResponse(reply)))

The program under test doesn't change. It runs purely, and the test then makes assertions about the exact requests it would have sent. The same program runs against a local llama.cpp server or a hosted endpoint by swapping in the http4s interpreters.

Why this shape keeps paying off

I've spent twenty years on systems where the expensive bugs sat at the boundaries: a field that was sometimes null, a retry that replayed a side effect, a schema that drifted from its validator. A free algebra doesn't remove those bugs. It moves them to one place, the interpreter, where a test interpreter sits right beside the real one and makes them easy to find.

The libraries on the front page follow this pattern. iron-mcp's example tools are written the same way, as Free programs over Nws[G] and Apod[G], and it gets the same separation at the protocol layer from derived types, so the schema a client sees can't disagree with the decoder.