My favorite programming talk is Tomas Petricek building a working spreadsheet in an hour in F#. He spends a big part of the hour just writing down types: a cell holds a number, or a formula, or a reference to another cell. Once the types are on the screen, the program mostly follows from them. The types do the thinking.
fedit is a terminal text editor built the same way, in F#. Much of the code was written by an AI agent, which makes the language the interesting variable. Here's how it's put together.
One record, one pure function
The whole editor is one immutable value: a single record holding the open buffers, the cursor, the file tree, even a half-recorded macro. One pure function moves it forward.
type Model =
{ Workspace: WorkspaceState
Editors: EditorsState
Prompt: PromptState
Panels: PanelsState
Focus: FocusTarget
Terminal: Size
Notification: Notification option
Config: Config
// … highlighting, plugins, keymap, macro registers
}
update takes a message and that record and returns a new record plus a list of effects to run. The only code that
touches the disk is runEffect, and it folds whatever it gets back into the loop as another message. The README states
the rule:
fedituses an Elm-style update loop. TheModelis pure data.Editor.updateis a pure function that takes aMsgand returns a new model plus a list of effects.runEffectis the only impure code path — it does file I/O and folds its result back into the loop as anotherMsg.
There's no "outside the loop" to hide state in. A keypress goes in, a new record comes out, and the screen is a function of the record.
This pattern has a name: the Elm Architecture, usually shortened to MVU, for model-view-update. State is one immutable
value, a pure update folds each message into the next value, and the view is a plain render of whatever state you're
holding. None of this started with F#. token-editor, the editor I built before this one,
runs the exact same loop in Rust: a single AppModel, one big Msg enum, an update that dispatches on it. But Rust
lets you cheat. Its update handlers can reach into AppModel and mutate it in place, and the compiler is fine with
that, so the pure boundary is something you have to keep choosing on purpose. F# gives you no comfortable way to mutate
a record, so returning a fresh one is just the path of least resistance. The language wants to be written this way. You
end up in MVU because fighting it is more work than going along with it.
If you've written Redux, you've written most of this loop already: actions in, a pure reducer out, the view re-rendered
from the result. The difference is what the compiler knows. A Redux action is a { type: string, payload: any } bag,
and nothing checks that your reducer handles every type, or that the payload is shaped the way a handler assumes.
fedit's Msg and Action are the same dispatch with every case a named variant the compiler can count, so the "did you
handle every action?" review that Redux leaves to you is a build error here.
The mode isn't stored
The prompt at the bottom does file-open, commands, search, and buffer-switching, depending on the first character you type. It has four modes, and none of them is stored.
type PromptMode =
| FilePicker // empty, or a first char that isn't a prefix
| Command // text starts with ':'
| Search // text starts with '/'
| Buffers // text starts with '@'
let modeOf (text: string) =
if text.Length = 0 then FilePicker
else
match text[0] with
| ':' -> Command
| '/' -> Search
| '@' -> Buffers
| _ -> FilePicker
The mode is recomputed from the text on every keystroke, so it can't fall out of step with it. There is no flag that
reads Search while the text starts with a colon, because there is no flag at all.
A closed set of actions
Every keybinding ends at one value of one type: a closed Action union with a single interpreter.
/// The single named vocabulary of everything a keybinding can trigger.
/// Pure data — no `Model` reference — so it compiles below `Editor`.
/// `Editor.runAction` is the one interpreter.
type Action =
| MoveLeft | MoveRight | MoveUp | MoveDown
| Indent | Unindent | Undo | Redo | Copy | Cut | Paste
| Save | SaveAs of string | Quit
| ToggleSidebar | FocusSidebar | FocusEditor
| Chain of Action list
| When of cond: Cond * thenDo: Action * elseDo: Action
// …
Closed means the rest of the editor can trust the set to be complete, and the compiler enforces that for free. Two
functions map an action to its kebab name and to a (category, description) for the keybindings listing, and both match
on Action with no wildcard:
/// Stable kebab name per `Action` case. Exhaustive so a new
/// Action forces a compile-time choice.
let actionName (action: Action) : string =
match action with
| MoveLeft -> "move-left"
| MoveWordLeft -> "move-word-left"
| Save -> "save"
| Goto _ -> "goto"
| RunPlugin _ -> "run-plugin"
// … every Action case, no wildcard
fedit builds with TreatWarningsAsErrors, so F#'s incomplete-match warning is a hard build failure. Add a case to
Action and neither function builds until the new action has a name and a one-line description. The comment on the
second one says so plainly:
The match is exhaustive, so a new Action case forces a compile-time update here rather than slipping through silently.
There's exactly one spot the compiler can't reach: allActions, a hand-written list with one value per case that
enumerates the actions shipping without a default binding. A list literal is invisible to exhaustiveness checking, so
that single gap is backstopped by a test instead. Even the one place the types can't see is checked by the build, not by
someone remembering to update a list.
fedit in the terminal
tiny little criminal
buffer goblin, syntax rat
where my union cases at?
— f sharp
Resolving a keystroke
A keybinding is a row of data: a keystroke, the context it applies in, and the action it fires.
type Binding =
{ Stroke: KeyStroke // one chord, or several for a sequence
Context: Context // Global | Editor | Sidebar | Prompt
Action: Action option } // None means this stroke is unbound
The option on the action is the part worth slowing down on. None doesn't mean "nothing is bound here." It means
"this key was deliberately freed." Those are different, so resolving a keystroke has three outcomes, not two:
type Resolution =
| Bound of Action
| Unbound
| NotBound
Bound runs the action. NotBound means nothing claimed the key, so it falls through, and a bare letter lands in the
buffer as typed text. Unbound means the key was freed on purpose, so it gets swallowed and nothing happens. Collapse
the last two into a boolean and a key you tried to disable quietly starts typing its own letter.
Bindings live in a plain text file and name actions as strings: save, move-word-left, goto:42. The bindable
surface is exactly that closed Action set, so the file can name everything the editor does and nothing it doesn't. And
because a binding is data, one key can hold a small decision. The default Ctrl+B is
When(SidebarVisible, FocusSidebar, Chain [RevealSidebar; FocusSidebar]): a branch and a sequence in a single config
row, still only allowed to name verbs from the one list.
One more thing falls out of a binding being data: a Stroke is a Chord list, not a single chord, so a binding can be
a whole key sequence, and a half-typed one is held until it resolves, vim-style.
Commands aren't actions
A keypress isn't the only way in. You can also type a colon-command at the prompt: :write, :theme tokyonight,
:open src/Editor.fs. That's a different kind of input, and it gets a different type.
A keystroke is instant. A command is typed one character at a time, so it passes through states a keystroke never has: it can be empty, valid so far, complete, or wrong.
type ParsedCommand =
| Empty
| Ready of Command
| Pending of string // valid so far, needs more input
| Invalid of string // unrecognized, with the reason
Pending and Invalid are what the prompt shows you mid-word. An action has no equivalent, because there's no
half-pressed Ctrl+S.
Where the two genuinely overlap, there's a small bridge instead of a merged type:
let ofCommand (command: Command) : Action option =
match command with
| Command.Write -> Some Action.Save
| Command.Quit -> Some Action.Quit
| Command.NextBuffer -> Some Action.NextBuffer
// … the handful with a real key-equivalent
| _ -> None // prompt-only verbs: :theme, :open, :goto, :plugin …
:write and Ctrl+S both reach the same Save value and run the same interpreter. But :theme tokyonight stays a
command, because nobody binds a key to one specific theme, and MoveLeft stays an action, because you don't type
:move-left to move the cursor. The bridge is nine one-line mappings and a _ -> None for everything that doesn't
cross over.
Macros are replayed input
Macros fall out of the loop almost for free, and the way they do is the cleanest payoff of the whole design. A register doesn't hold a script of actions. It holds the raw keys you pressed:
Registers: Map<char, Chord list> // one entry per register, chords in press order
Recording: char option // Some 'a' while recording into register a
Replaying: bool // true while injected keys are flowing back in
Recording is a flag and an append hook in update that tacks each incoming chord onto the open register. Replaying
feeds those chords back into the front of the same loop, with Replaying set so the injected keys don't get recorded a
second time.
There's no separate macro engine re-implementing what the editor does. There can't be a drift between "what a macro does" and "what the keys do," because they're the same path. Because the loop is deterministic, the same keys against the same starting model give the same result. Replaying a macro just runs your old input back through the route it took the first time.
Drawing is a diff
Output works the same way. A frame is a plain value: a grid of cells, each one a glyph and a style.
[<Struct>]
type Cell = { Glyph: char; Style: Style }
type Screen =
{ Width: int
Height: int
Cells: Cell[,]
Cursor: Cursor option }
The view is a pure function from Model to Screen. It fills in cells and nothing else: it never touches the terminal,
and it doesn't emit a single escape code. The screen really is a function of the record, just another fold from state to
a value.
That value is what makes redrawing cheap. The blunt way to update a terminal is to clear it and reprint the whole thing, which flickers and wastes bytes. fedit keeps the last frame it painted and walks it against the new one, cell by cell:
if not (sameAsPrev row col) then
let cell = next.Cells[row, col]
if row <> lastRow || col <> lastCol + 1 then
append builder (cursorPosition row col) // ESC[row;colH, only when we jumped
if currentStyle <> ValueSome cell.Style then
append builder (styleToAnsiSequence colorSupport cell.Style) // SGR color, only on a change
currentStyle <- ValueSome cell.Style
appendChar builder cell.Glyph
Two cells compare with plain struct equality, no allocation and no stringifying. A cell that didn't change emits
nothing. A run of changed cells sitting next to each other emits one cursor move and then just the glyphs, because the
terminal advances on its own after each one. The color sequence goes out only when the style actually flips, and it's
downsampled to whatever the terminal can show first, so truecolor degrades cleanly to 256 or 16 colors on an older one.
There's no list of dirty regions built up and traversed. The diff is the walk, appending ANSI into a StringBuilder
that's flushed in a single write.
The one thing that can't be pure here is the last-painted frame, so it isn't. It sits in the terminal layer, outside the loop, swapped after every paint:
let writeFrame (t: TerminalState) (screen: Screen) =
Renderer.render t.Writer t.Capabilities.ColorSupport t.PreviousFrame screen
t.PreviousFrame <- ValueSome screen
It's the same split as everywhere else: the pure side works out what the screen should be, and the impure side remembers what's currently on it and the fewest bytes that get from one to the other.
Errors live in the type
Anything that can fail comes back wrapped, so the failure has to be dealt with before the value is reachable.
type Msg =
| WorkspaceLoaded of Result<FileNode * Map<string, FileNode> * string list * int, string>
| FileOpened of path: string * intent: OpenIntent * target: Position option * Result<string, string>
| BufferSaved of bufferId: int * path: string * revision: int * Result<unit, string>
| ClipboardPasted of Result<string, string>
// …
You can't pull the contents out of FileOpened without going through the Error branch. A forgotten failed-read isn't
a discipline problem or a review problem. It doesn't build.
None of this makes the code correct. Types pin down the shape of the data, not the logic over it. The piece-table code that splits and rejoins spans on every insert and delete is correct in all its types while still sitting one off-by-one away from mangling a line, and no signature catches that. What the types do catch is the broader, dumber class of mistake: the unhandled case, the impossible state, the result used without its error. That is most of what goes wrong when code gets written fast, which is the whole reason an editor can be written this loosely and still stand up.
fedit also picked up a mention in F# Weekly, which was a nice surprise for a project this young.
