This is a written version of my “Your docs are a program” talk, presented at Lambda Days 2025. You can find the slides here.
catphoto

Imagine it’s the late 90s. You’ve discovered the joy of the internet. In particular, you’ve discovered cat photos. You’re inspired to write a program to create your own cat photo album.
Under the hood, the cat photo code is pretty simple. It’s just a map of names to photo urls. Here’s what it looks like in Scala:
case class Album(photos: Map[String, String] = Map.empty) { def addPhoto(catname: String, photo: String): Album = Album(photos + ((catname, photo))) def getPhoto(catname: String): String = photos(catname) }
If you’re not familiar with Scala, here’s some equivalent Elixir code:
def add_photo(album, catname, photo) do Map.put(album, catname, photo) end def get_photo(album, catname) do album[catname] end
Questions
You’re itching to share the joy of cat photos with everyone else, so you publish your code as a library. Now anyone can create an album of their very own.
You show the library to your friends and, not surprisingly, they have a lot of questions. They might ask:
How do I get started?
Why should I store cat photos?
How do I get my photo?
What should I pass to the
addPhoto
function?
Thankfully, you only have a few friends (a few human ones, anyway). You can answer their questions by just speaking to them. You can show each of them the ropes individually.
But soon, cat photos become really popular. You’re inundated with questions from people you don’t even know, some of which you don’t even understand.
Perhaps it’s time you wrote some docs.
Types of docs
Documentation is the way in which we answer questions for the masses. They’re a way of communicating with the people who use our code.
In the field of technical writing, there are actually different kinds of docs for different kinds of questions.
For example, a developer first encountering catphoto
might want a
tutorial-like getting started guide. Primed with her laptop, she’ll walk
through each step and write some code for herself.
Later on, she might walk her dog in the park, and think about key concepts, such as why cat photos are different to dog photos. During that walk, she could browse through a conceptual guide.
When she gets back to her desk, she may have a clear idea of what she wants to do, and could be directed by a how-to guide. And if she’s in the weeds of specific functions, she could have a glance at a reference guide.
All in all, she’d consult four different kinds of guide:
Tutorial: Getting started
Conceptual guide: Motivation and model
How-to: Task focused
Reference: Code focused
Depending on how she’s reading these guides, she’ll prefer them in different formats. For example, she might want to look at reference guides from within her IDE, but look at everything else in a web browser.
Doc Tools
Fast forward to 2025. It’s about time you wrote some docs for
catphoto
.
You search for tools to use, and are overwhelmed by the selection. There’s not just markup, but markdown in several different flavours, and a plethora of frameworks to work with them, as well as language-specific tools. How do you pick the right one?
It’s easy to feel daunted by this. It’s hard enough to figure out what sort of docs to write, let alone what to write them with.
Over the next half hour, we’ll step away from this problem and consider a different way of viewing docs.
We’ll learn to think about docs as programs. In doing so, we’ll understand why there’s such a huge variety of tools out there, and might even be inspired to be more creative with our own documentation.
Docs are artifacts
We’ll ease into the concept of docs as programs. As a start, let’s consider how docs are artifacts.
In case you’re not familiar with the term, an artifact is a package of things produced by a pipeline that is published to someone else to use. In the JVM ecosystem, a typical artifact is a jar. It’s published on a repository like Maven, and this distributed to anyone who wants to use it or run it. Jars are created from source code by going through a sort of pipeline.
Reference docs
To see how docs are artifacts, let’s take a look at a snippet of
a reference doc. This snippet documents the getPhoto
function. In order to explain what getPhoto
does, we
include a helpful code snippet:
catphoto.scala
/** Gets the url of a random photo of a given cat. * * {{{ * >>> val album = Album() * >>> val nextAlbum = album.addPhoto("Mao", "mao.png") * * >>> nextAlbum.getPhoto("Mao") * mao.png * }}} */ def getPhoto(name: String): String = ???
The documentation comment is put through a pipeline to generate API docs. These are packaged up as a HTML artifact to be rendered in a browser, or are rendered directly in an IDE.
Very generally, a programming pipeline converts source files into some kind of artifact, which is eventually ran by some kind of tool.
The programming languages we like to work in have a lot of validation.
A program can have bugs, and so we have type systems, linters and tests to catch those bugs early in the pipeline, before the artifact is published.
Docs can also have bugs: not only spelling mistakes, you can refer to a function that doesn’t exist, or write code in our docs that doesn’t actually run.
Is it possible to catch bugs in our docs?
Doctest
Indeed we can. Depending on the language you’re documenting, there
are plenty of tools to run your docs as test. For Scala, we can use
sbt‑doctest
.
Doctests were made popular in Python, but now exist in many ecosystems.
The doctest tool extracts the code snippets in our documentation comments and produces tests from them.
catphoto.scala
/** Gets the url of a random photo of a given cat. * * {{{ * >>> val album = Album() * >>> val nextAlbum = album.addPhoto("Mao", "mao.png") * * >>> nextAlbum.getPhoto("Mao") * mao.png * }}} */ def getPhoto(name: String): String = ...
We can run the tests using our build tool, in this case sbt
.
sbt> test + CatPhoto.DocTest [success]
We’ve asserted that album.getPhoto("Mao")
does indeed
return mao.png
. Our docs were correct.
Suppose we change the return type of our getPhoto
function. It’s easy to forget to update our docs.
catphoto.scala
/** Gets the url of a random photo of a given cat. * * {{{ * >>> val album = Album() * >>> val nextAlbum = album.addPhoto("Mao", "mao.png") * * >>> nextAlbum.getPhoto("Mao") * mao.png * }}} */ def getPhoto(name: String): Option[String] = ...
sbt‑doctest
has our back. The test
command fails.
sbt> test - CatPhoto.DocTest [error] Failed tests
We can update our docs with the correct output value.
catphoto.scala
/** Gets the url of a random photo of a given cat. * * {{{ * >>> val album = Album() * >>> val nextAlbum = album.addPhoto("Mao", "mao.png") * * >>> nextAlbum.getPhoto("Mao") * Some("mao.png") * }}} */ def getPhoto(name: String): Option[String] = ???
After this, the tests now pass.
sbt> test + CatPhoto.DocTest [success]
We can integrate these tests into our CI pipelines to make sure we only ever publish valid docs. We no longer need to worry about outdated snippets.
Guides
sbt‑doctest
works well for reference documentation, where
our code is written in comments, but what about other sorts of
guides?
We generally write tutorials in markdown. Here’s an example of a getting started guide:
getting-started.md
# Getting started Create a photo album. ```scala val album = Album() ``` Add a photo for a cat named `Mao`. ```scala val nextAlbum = album.addPhoto("Mao", "mao.png") ```
Just as with our reference docs, guides can also be considered an artifact. The markdown source is transformed into a HTML artifact, which is then viewed in a user’s browser.
This means we also have the opportunity to validate it.
mdoc
To validate our getting started guide, we’ll use a tool called
mdoc
.
We use it by adding the mdoc
keyword
next to snippets we want it to run.
getting-started.md
# Getting started Create a photo album. ```scala mdoc val album = Album() ``` Add a photo for a cat named `Mao`. ```scala mdoc val nextAlbum = album.addPhoto("Mao", "mao.png") ```
We can then run the mdoc
command.
Our guide looks valid, doesn’t it? If we run mdoc
, we
find that there is in fact a problem: we’ve forgotten to specify where
Album
is imported from:
> mdoc --in getting-started.md error: getting-started.md:6:13: Not found: Album - did you mean album? val album = Album() ^^^^^
This is easy enough to resolve.
getting-started.md
# Getting started Import `catphoto.Album`. ```scala mdoc import catphoto.Album ```
> mdoc --in getting-started.md [success]
How many times have you come across a blog post that doesn’t define
where its symbols come from? With mdoc
, you can be sure
your users will never have this experience.
Instead of asserting its output, mdoc
produces a new
markdown file with the output written inside it. In our example,
writes an out/getting‑started.md
file.
out/getting-started.md
# Getting started Import `catphoto.Album` ```scala import catphoto.Album ``` Create a photo album. ```scala val album = Album() // album: Album = Album() ```
Our users can follow along and compare the comments to check they’re on the right track.
Variable substitution
By considering our docs as having a pipeline, we can do more than just
validation. Suppose catphoto
was rapidly evolving. It
would be sensible to label our docs with the version they correspond
do.
Using the analogy of docs as programs, the version could be something like a variable.
We can achieve this with mdoc
by writing
VERSION
between two @@
characters.
getting-started.md
# Getting started
This guide is for @@VERSION@@.
mdoc
will then substitute @@VERSION@@
with whatever text we pass to it.
For example, the command:
> mdoc --site.VERSION="0.1.42" --in getting-started.md
Produces an output file with the version 0.1.42
substituted in:
out/getting-started.md
# Getting started
This guide is for 0.1.42.
Displaying output
mdoc
can do more than just validate our code and
substitute variables. It can display the output of our code in
different ways.
Our library is all about cat photos. Wouldn’t it be great if we could display the results of the code snippets as images?
out/getting-started.md
Get a random photo for Mao. ```scala nextAlbum.getPhoto("Mao") // mao.png ``` // Can we display mao.png ?
We can do this using a modifier. A modifier is a Scala class that converts the output of a code snippet into a piece of markdown.
We can write a CatPhotoModifier
to display cat photos:
CatPhotoModifier.scala
class CatPhotoModifier extends PostModifier { val name = "catphoto" def process(ctx: PostModifierContext): String = { ctx.lastValue match { case url: String => s"""```scala |${ctx.outputCode} |``` |""" case _ => "" } } }
We can use our modifier by annotating our code snippet with catphoto
.
getting-started.md
Get a random photo for Mao. ```scala mdoc:catphoto nextAlbum.getPhoto("Mao") ```
When mdoc runs, it calls our custom code and inserts the markdown for an image in the output.
out/getting-started.md
Get a random photo for Mao. ```scala nextAlbum.getPhoto("Mao") // mao.png ``` 
Considering docs as artifacts allows us to compile, test and otherwise validate them. We can integrate these validation checks into CI pipelines, so our docs would never break due to code changes. As part of the artifact generation, we can substitute variables, and even display the output of code snippets in interesting ways.
But docs are similar to programs in many other ways too.
Docs are trees
Tools such as doctest
and mdoc
are effectively parse the doc file into a structure to identify the
code snippets to run.
They then execute code snippets in an environment and insert their results back to produce another file.
This leads to an interesting observation: docs are also trees.
When we code, we code in a tree-like structure. Whatever programming language we use, parentheses or not, our text is parsed into some kind of tree, then validated through some kind of type system, or just interpreted as-is.
When we write docs, we also write in a structure. We have markup for headings, keywords and code blocks.
We can have a look at the tree for a markdown document in AST Explorer.

As well as being tree-like, our markdown is transformed into a tree artifact: HTML. The following markdown:
# Getting started Import `catphoto.Album`
can be transformed into the following HTML:
<html> <h1>Getting started</h1> <p> Import <span>catphoto.Album</span> </p> </html>
A lot of doc tools work by manipulating the tree as it transforms from markdown to HTML.
Docs are interactive
We’ve considered how docs are artifacts, and how those artifacts are HTML trees. But we’ve assumed that the HTML artifact corresponds to a static website.
What if we could give our users something they could run?
Livebooks
We can do so with Livebooks.
A Livebook looks like any other markdown file, but with Elixir code snippets. Here’s an example of one for a cat photo tutorial:
getting-started.livemd
## Cat Photo Tutorial Let's create a cat photo album. ```elixir album = CatPhoto.new() ``` ## Adding a photo to the photo library You can add a photo to the library with `add_photo`. ```elixir album = album |> CatPhoto.add_photo(:mao, "mao.png") ```
Unlike typical markdown documents, Livebooks are interactive notebooks. Notebooks are used in the data engineering space to explore datasets, or share proof of concepts for algorithms.
But aside from data exploration, Livebooks also happen to be great for documentation.
A Livebook is an environment for evaluating code, in particular Elixir code. They allow users to execute and modify code snippets on the fly. Here’s a brief demo:
By using a Livebook, our users can actually instantiate a cat photo album, and play with it by adding their own photos. The Livebook editor integrates with hexdocs, Elixir’s reference documentation site, so users can easily jump from a guide to its reference documentation.
Visualizations
As well as executing user code, Livebooks let us embed visualizations based on that code.
Suppose we want to display all the images in the user’s photo
album. We could use the Kino visualization library to
generate a list of
<img>
tags and embed these in the Livebook.
defmodule CatPhoto.Kino do use Kino.JS def new(album) do htmls = for {_, v} <- album do "<img src='#{v}'/>" end html = Enum.join(htmls) Kino.JS.new(__MODULE__, html) end asset "main.js" do """ export function init(ctx, html) { ctx.root.innerHTML = html; } """ end end
We can then display our user’s photos.
One problem with Livebooks is that they need a server to run. Your users need to install a tool and download and run the notebooks locally, or have an account with a cloud provider. This may seem like a small step, but it’s a large barrier for new users.
Using the browser
There’s an interactive environment installed on practically everyone’s computer, regardless of whether they code frequently or not. It’s known as a web browser.
Another solution for interactive docs is to embed JavaScript in them. A lot of languages cross-compile to JS. This means that the user can run snippets in their browser, as long as they’ve been pre-compiled.
As an example, Scala compiles to a JVM artifact using
the Scala compiler, and to a JavaScript artifact using
Scala.js
. This
means that a Scala library can be
compiled to JavaScript, which can then be embedded in library
documentation.
Here’s an example of it in action using aquascape, a tool for documenting a Scala stream processing library.
Using Scala.js
, aquascape displays a code snippet and an input box for controlling one of the
parameters to that code. When the code executes, it generates a
diagram describing a stream process.
Even though the underlying library is for Scala, the code is being run in the browser. There’s no server which is taking the user’s input and running it on a server-side environment.
You can hop over to the aquascape website and type 10000
into an
input box, and the only person you’ll upset is yourself. For a doc
author, that’s deeply
satisfying. When writing documentation, you don’t want to
worry about all the vulnerabilities your docs could have.
Since our docs are browser based, we can use whatever HTML we wish to display our docs more creatively. The docs above displays its output via a diagram, not just a code result.
Language documentation
We can use this trick to write creative docs for the Scala language itself. The Scala compiler doesn’t run in the browser, but the scalameta parser does.
Here’s the Scala explorer a work-in-progress tool for exploring Scala language features:
Someone confused about a Scala code snippet can just pop it in the explorer and jump to the docs for whichever feature they wish.
Docs are programs
We’ve seen that docs, like code, are trees. They are parsed, manipulated and finally rendered to produce HTML or some other artifact. In the process of building the artifact, our doc tools validate snippets by running code, can substitute variables and manipulate the code output.
Does this mean that markdown is a programming language? Is the text below actually a program?
getting-started.md
# Getting started This guide is for @@VERSION@@. Import `catphoto.Album`. ```scala mdoc import catphoto.Album ``` ... Add a photo for a cat named `Mao`. ```scala mdoc val nextAlbum = album.addPhoto("Mao", "mao.png") ```
Perhaps markdown is a language, or perhaps it isn’t. It depends on what you define a language to be.
What’s certain, however, is that it’s not a programming language we like.
No one wakes up on a Saturday morning and thinks, “You know what? Today I’m going to code some generative poetry. And I’ll do it using markdown.”. It’s just not a satisfying experience.
The language we like have well defined rules for how they should behave. They are expressive and extensible: we can use their building blocks to solve all sorts of different problems.
Markdown is a good tool for writing text: we can write in a “What you see is what you get” tree-like structure. But it lacks the expressiveness of a typical programming language.
Sometimes, this expressiveness would be very useful.
As a concrete example, let’s introduce a new validation check for our docs. All cats we name in our docs need to be thanked in the credits. Here’s the markdown for our credits page:
thank-you.md
# Thank you! The following cats need lots of treats: - Mao - Popcorn
Bugs
Suppose we accidentally reference a new cat in our guide. Cinder
isn’t
present in our credits page, and so we’ve introduced a bug. Is it
possible to pick up on this?
getting-started.md
Add a photo for Cinder.
Let’s take a step back and think about how we might solve this if it were code.
We could have a thank‑you.md
source file that
declared a list of cats. Our other files, such as
getting‑started.md
could pull in that list and validate cat
names. There could be a function that would look up
the cat in the list and raise some kind of error if the cat wasn’t found.
We can’t write this in markdown. But we can if we use a different language. To give you a clue as to which one, we’re going to take a closer look at our HTML tree.
<html> <h1>Getting started</h1> <p> Import <span>catphoto.Album</span> </p> </html>
Let’s switch the angle brackets to round ones:
(html (h1 Getting started h1) (p Import (span catphoto.Album span) p) html)
And then delete the end tags:
(html (h1 Getting started ) (p Import (span catphoto.Album ) ) )
And voila! We actually end up with code. In particular, we end up with a Lisp. To solve this problem, we’re going to use a Lisp called Racket.
Racket
Here’s a very brief tour of Racket for Lisp newcomers.
We can declare functions and values with
define
.We can have strings, such as
"Mao"
and lists such as'("Mao" "Popcorn")
.We declare functions with braces, for example
stroke
, and we call functions with braces too, for examplestring‑append
.
cats.rkt
#lang racket (define myCat "Mao") (define cats '("Mao" "Popcorn")) (define (stroke name) (string-append "Stroking " name))
Whether you’re looking at a list, a function definition, or a function call is very contextual. This is initially very confusing, but it’s also a large part of what makes Racket so well-suited to this task.
We can import code in the Racket REPL using
require
. If we import mycat.rkt
, we can
use the myCat
variable, and the stroke
function:
> racket λ> λ> (require "cats.rkt") λ> myCat "Mao" λ> (stroke myCat) "Stroking Mao"
A language to write languages
Racket is powerful because it is a language to write languages.
By “writing a language”, we mean a domain-specific language. All text is interpreted as a language, with its own parser, evaluation steps, and output.
There are actually several domain-specific languages for documentation. We’re going to use a lesser known one called pollen, a language specifically for writing books.
Here’s an example of some pollen code:
thank-you.html.pmd
#lang pollen # Thank you! The following cats need lots of treats: - Mao - Popcorn
This looks like markdown, but it’s actually code. We know it’s code because we can evaluate it in a Racket REPL.
> racket λ> (require "thank-you.html.pmd") λ> doc '(root (h1 ((id "thank-you")) "Thank you!") (p "The following cats need lots of treats:") (ul (li "Mao") (li "Popcorn")))
The file exports a doc
variable with the value
'(root ...)
. This corresponds to a list of
h1
and p
elements, just like the one we
need for HTML.
We can take this list and convert it to a HTML string.
λ> (->html doc) "<h1 id=\"thank-you\">Thank you!</h1><p>The following cats …"
But more importantly, we can import it into other Racket
files. Here’s a file written in Racket (not pollen). It defines a
catname
function that checks whether a cat has been
thanked.
It does this by importing the symbols defined in thank‑you.html.pmd
.
catname.rkt
#lang racket (require "thank-you.html.pmd") ;; Get the cats from the doc (define cats (select* 'li doc)) ;; catname is a function (define (catname name) (if (member name cats) name (error "A cat wasn’t thanked!" name)))
Not only can we import pollen code into our Racket code, we can call Racket functions in pollen too. To do that, we use the
lozenge symbol ◊
.
Learning to type the lozenge is probably the most challenging aspect of pollen. If you’re familiar with language design, you’ll know why it uses a character no-one ever wants to type.
getting-started.html.pmd
#lang pollen ◊(require "catname.rkt") Add a photo for ◊(catname "Cinder").
If we evaluate this in our REPL, we get the error we want:
λ> (require "getting-started.html.pmd") A cat wasn't thanked! "Cinder"
Our docs have been evaluated as a program. When we ran
getting‑started.html.pmd
, the code in
ctatname.rkt
was evaluated, and in turn so was the code
in thank‑you.html.pmd
.
By looking at our docs as code, we can see how the different parts of our program are connected.
Racket is a fully-fledged programming language. With some effort, we can use it to do
everything we could with sbt‑doctest
and mdoc
: we can evaluate code
snippets, substitute variables, and display images. We can write our
own functions to validate text, not just code.
We can also be more creative with what we display. Here’s an example of an interactive code snippet in The Craft of Emacs, written fairly easily in Racket:
Instead of being overwhelmed by a huge chunk of code, the user can walk through the code in the way the author intended.
If you look closely, you’ll see a few other nice features: the parentheses in the Lisp code are highlighted with different colours depending on their indentation level. This is standard for Lisp editors, and with a few lines of Racket code we can bring that standard to the browser.
Perhaps the most vital bit of the reader is something you can’t see. The code snippet is accessible to visually impaired users. If someone has trouble viewing the code, they can use a screen reader and their keyboard to explore the code snippet. The code will be read to them in a way that makes sense, instead of as a jumble of parentheses.
It would be possible to write these tools in other languages, but it’s much easier to conceptualize them in Racket, where the documentation is itself code.
Docs are programs
We’ve seen that docs can be programs in many different ways. We eased into the concept by looking at documentation artifacts, and loosely comparing them to program artifacts.
We learned that markdown is transformed into a tree-like structure, and it’s this tree that forms the basis of most doc validation tools.
We even saw that our docs could be interactive: with Livebook and
Scala.js
, our docs could have a runtime.
And with Racket, docs truly are programs. They can be intertwined with Racket code to create many more creative pipelines.
Where next?
The concept of docs as programs is a very abstract, and you’re probably wondering how to apply it.
You may not use Scala or Elixir, and probably won’t touch Racket (although I hope you do!). But you can start to think about your documentation in a different way. Instead of thinking about your docs as just text and code, think about how you would solve problems that exist in them if they were written in a programming language, and try and think about the other kinds of problems you would encounter when you are writing them.
I look forward to the creative documentation people will write once they start thinking in this way.
Explore
If you’d like to read more, explore the following links:
Diataxis framework, a technical writing framework for structuring documentation.
A Core Calculus for Documents is an excellent paper by Will Crichton and Shriram Krishnamurthi. It goes into the weeds of the calculus of templating languages, of which pollen is one.
Beautiful Racket by Matthew Butterick, a book on how to write Racket domain-specific languages.
Pollen, a domain-specific language for writing books.
Scribble is the primary language used to document Racket code. It is much more widely used than pollen, and has features that lend itself to reference docs.
sbt-doctest, a Scala tool for creating tests out of reference docs.
mdoc, a Scala tool for validating markdown-based guides.
The livebook documentation, best read as a collection of livebooks.
Adam Lancaster’s “Just what the docs ordered” article is a great tutorial on how to generate livebooks from module docs. It briefly explores
moduledoc
, andex_doc
for doctests.Kino is a library of livebook widgets, and tools for constructing them.
Unison has an AST for doc comments, with the ability to call functions that manipulate the tree.
LaTeX is a well-used tool in the scientific documentation space.
Find me
If you’re interested in hearing more, you can find me online:
LinkedIn: zainab-ali-fp
Bluesky: @zainab.pureasync.com
My newsletter: buttondown.com/zainab.
You can also check out my early access book on Functional Stream Processing in Scala, and get 50% off with the code LAMBDADAYS.
If you’d like to meet in person, catch me at Scala Days 2025.
Thank you for reading!