Building things in PFCL

PFCL language reference · composure.systems

The rest of this site explains what PFCL ispurity, totality, content-addressed identity. This page is for programmers who just want to know how to do things. Fair warning: a lot of the answers are “you don’t.”

If you write software for a living you have a specific set of questions, and most of them start with “okay, but how do I…”. Here they are, and you’ll notice a pattern in the answers.

1.How do I compile in PFCL?

You don’t. You load — and load is where a compiler’s promises get kept.

There’s no build step, no artifacts, no linker, no target triple. Loading a file runs the whole gauntlet in one pass: parse, typecheck, resolve every name to a SHA-256 hash, reject cycles. If the file survives, you’re holding more than a compiler usually hands you — not just “well-typed,” but unable to crash. The next two answers explain what that buys.

The reference implementation is a tree-walking interpreter in Rust, and that’s a deliberate place to start, not an apology. A function’s hash names its source, never its machine code — so faster realizations of the same hash can arrive later without changing a single identity. Compilation, here, is an optimization detail. Loading is the semantic event.

2.How do I debug in PFCL?

You don’t.

Checking the values that flow between functions is a real need — you’ll do it constantly. What you won’t do is debug, in the sense the word actually carries. A step-through debugger is an investigation through time and state: breakpoints, watched variables, hunting for the moment a value went wrong — because in a stateful program the same line can produce different results on different runs. The debugger’s entire value proposition is answering “how did this variable get this value?” — a question about a history of mutations.

In PFCL that question has no referent. A value passed between two functions has no history; it’s a pure function of its inputs, identical on every run. So “check the value at this point” collapses from an investigation into a lookup, and there are three ways to do one. Evaluate the sub-expression: paste the inner fold into the REPL with the same inputs, and you’re looking at exactly the value that flows there — no catching it on the third iteration, because nothing is iteration-dependent. Name the intermediates: the function is a value-to-value map, so temporarily return mu — or a record of everything mid-flight — and read the output; the trace is the return value. Or log as data: printf-style logging is one more record appended to the command list your function already returns (see 08), and the host prints it. In all three, inspection is just evaluation — local, reproducible, stateless.

The failures a debugger usually hunts for are caught earlier and elsewhere. The typechecker rejects ill-typed compositions before anything runs. Exhaustive match means you can’t silently forget a case. A cycle check runs at load time. The REPL has :inspect for looking inside any entry; a fuller audit suite is on the roadmap. What’s gone is the 2 a.m. “how is this field null” session — because there are no fields and no null.

3.How do I test this?

You test exactly one thing: whether it’s the function you meant.

An entire tier of testing disappears, because it has nothing to catch. A composition that loads cannot hit a syntax error, a type error, or a missing case — those are spent at load time, not discovered at runtime. An infinite loop is different in kind: no type system can rule that out statically (that’s the halting problem, not a PFCL gap), so instead of a load-time guarantee, evaluation carries a runtime bound — a fixed iteration ceiling on recursion, a fixed depth ceiling on evaluation itself — that turns “this doesn’t terminate” into a clean, reported error instead of a hang or a crash. Weaker than “caught before running,” and still the property that matters: nothing silently loops forever, and nothing takes the process down with it. Evaluation comes back on every input, every time — with a value, or with one of those clean bounded errors; never a hang, never a crash. So there’s nothing to smoke-test: no mocks, no fixtures, no clock to freeze, no network to intercept, no flaky test that only fails in CI. (The commands your function returns can still fail out in the world — a file can be missing — but that failure belongs to the host and comes back as data — see 08 — not as a crash inside your function.)

What no machinery in any language can check is whether the value that comes back is the one you wanted. Types constrain shape, not meaning: (Int, Int) → Int is addition and subtraction alike, and a composition can be perfectly valid and perfectly wrong. Specify a grid game with alternating turns and a win condition, and you can get a flawless tic-tac-toe — when what you had in your head was chess. Both satisfy every word you said; the system delivered the smallest thing that does. It has no access to what you meant. That gap isn’t a PFCL limitation. It’s what “semantics” means, and it gets tested outside the language.

Outside looks like this: examples and laws travel with each catalog entry — mean([2.0, 4.0]) = 3.0, commutativity, round-trip properties — still your statements of intent, but checkable, permanent, and attached to the function’s hash. The catalog’s own structure is checked at every load — entry validation and the cycle check — with a dedicated audit suite on the roadmap. And at the top sits you, looking at the output. So: PFCL retires the question “does it crash?” completely — and hands back, undiluted, the only question that was ever really yours: is this the function I meant?

4.How do I import a module or add a dependency?

You don’t. There are no imports, no module system, and no package manager.

Every function lives in a content-addressed catalog, and its identity is the SHA-256 of its typed source. You don’t import list.map; you just name it, and the runtime resolves the name to a hash when the file loads. What’s in scope is whatever catalogs you loaded: --catalog ./std --catalog ./mycompany.

So there’s no node_modules, no lockfile, no build step that fetches the internet. Two functions with byte-identical typed bodies are the same function — in every catalog that contains them, forever. Names are just aliases to hashes.

5.Then how do I deal with dependency hell and version conflicts?

You don’t have any. It’s structurally impossible.

There are no versions, so there are no diamond dependencies. The diamond problem needs two versions of the same thing to exist at once; when the same source is the same hash, that can’t happen. math.mean at a given hash is that function everywhere, permanently.

The catalog is append-only by spec: once a function is published, its hash and its name resolution survive forever. Bug fixes ship as new entries (math.clamp_v2); the original stays callable, and nobody gets a forced upgrade at 5 p.m. on a Friday. And the catalog is plain YAML in git, so cat, grep, git blame, and sha256sum all just work. No registry, no account.

6.How do I write a loop?

There is no for. There is no while.

Most of the time you fold, map, or filter over a list and never think about recursion at all. When you genuinely need it, fix is the one recursion primitive, and it always runs under a hard iteration guard — a fixed ceiling that turns a non-terminating fix into a clean, reported error rather than a hang or a crash, automatically, on every use. On top of that, a catalog entry using fix can document a termination_witness — a structural, numeric, or fuel-bounded justification for why this particular recursion actually terminates. Worth being precise about what that second part is: a trusted, human-written attestation, checked and signed off by a person, not (yet) verified by any prover. The guard is automatic and universal; the witness is optional documentation of why, for the next person reading the entry.

Which means “accidentally infinite loop” can’t hang production or take it down — the worst case is a bounded, reported error in the logs. If you can’t say why your recursion stops, the language would like you to say so too — up front, in the witness.

7.How do I handle errors? Where’s try / catch?

There isn’t one. No exceptions, no throw, no Result monad.

A value that might be missing is a Maybe, and the only way to consume one is match — which the typechecker forces to be exhaustive. You cannot forget the nothing arm; the code won’t load. Most partial operations don’t even hand you a Maybe; they take a default instead, as in list.head(default, xs). And no Result<a, E> as a catalog type — Maybe is the right default for ordinary optionality. If you need two genuinely distinguishable cases carrying different payloads, Left/Right work as a real, general either-or (any uppercase constructor does — Left(x), Right(y), or your own names, with real pattern matching against them) — just not a first-class, catalog-documented type the way Maybe is.

Errors that carry a reason ride the same channel as effects — as command records a host handles — rather than as in-band control flow threaded through every call site. There is a tradeoff: deeply nested match can get pyramid-shaped. But that’s a readability cost, not a correctness one. Every failure path is still statically accounted for; an unwrap() that compiles and panics has no equivalent here.

8.How do I print, read a file, or make a network call?

Your function doesn’t. It returns a description of what should happen.

A PFCL function returns a List<Command> — plain records like {kind: "stdout", text: "hi"}. It never performs the effect itself; a host reads the list and does the work. There’s no IO monad and no algebraic effect rows. An effect is a string plus a host that knows how to handle it.

Adding a new effect is adding a new kind and teaching some executor to read it. And because the commands are just data, the same list can be run by an executor written in Rust, or Python, or two of them side by side. Purity holds at every level of a composition, right up to the boundary where a host reads the commands.

9.Fine — show me an actual program.

Yes — but the obvious one is a trap.

Here’s what your imperative instincts produce. It loads and runs, which is what makes it a well-formed mistake:

main : List<String> -> List<Command>
main = \(args).
  let xs  = [12.0, 17.0, 23.0, 9.0, 31.0] in
  let n   = int.to_float(list.length(xs)) in
  let sum = list.fold_left(\(acc, x). float.add(acc, x), 0.0, xs) in
  let mu  = float.divide(sum, n) in
  [{kind: "stdout", text: float.to_string(mu)}]

One main: initialize, compute, print. That’s a Python script wearing PFCL syntax. And nothing blocks it — the language checks properties, not taste, so this will load forever. What makes it a mistake is that it’s sterile. In Python the monolithic script gets rewarded: someone can import it. Here there are no imports, so computation buried inside main has no name, no hash, no identity — it’s invisible to search and unreachable by every other program ever written. The mean logic is trapped: unusable by anyone. Including you, next week.

The unit of programming here isn’t the program. It’s the catalog entry. The computation gets a name of its own:

math.mean : List<Float> -> Float
math.mean = \(xs).
  float.divide(
    list.fold_left(\(acc, x). float.add(acc, x), 0.0, xs),
    int.to_float(list.length(xs)))

That entry now has a SHA-256 identity, permanently. It’s findable by type and description (see 11), it slots into every future composition, and it never has to be written again — by you or by anyone whose catalog contains it. Which leaves main with almost nothing to do, and that’s the point:

main : List<String> -> List<Command>
main = \(args).
  let mu = math.mean([12.0, 17.0, 23.0, 9.0, 31.0]) in
  [{kind: "stdout", text: float.to_string(mu)}]

This is the shape of every PFCL program: a catalog of small pure functions doing all the thinking, and thin edge functions — the only ones whose types mention Command — adapting results to the host. Rule of thumb: if a function both computes something and emits commands, it’s two functions. The computation belongs in the catalog; the edge belongs at the edge. math.mean resolved to a hash the moment the file loaded, with no import anywhere — and each program you assemble this way makes the next one shorter, because the catalog only grows.

10.How do I publish a function?

You don’t upload anything. You commit.

A catalog is a directory of YAML files in a git repository — one file per function, carrying the typed source, the description, the examples and laws. Publishing means your entry lands in a catalog that somebody loads. There is no package registry to sign up for and no upload pipeline separate from version control: git push is the entire publication mechanism, and access control is whatever the repository’s access control is. That’s equally true for a public catalog on Codeberg and a licensed one behind your company’s wall. Discovery across public catalogs is designed as an index that crawls, not a gateway you submit to — a hash is self-certifying, so nothing will ever need a server to vouch for it. (The index, like promotion, is topology that’s settled on paper and not yet running.)

What gets in is decided by whoever owns the catalog. Your own takes exactly a commit — the loader validates the entry, the audit tooling checks structure, and the hash is minted from the canonical source. The standard catalog, as it stands today, is a direct contribution target: write the YAML, make sure it loads and the example programs still pass, open a PR (see CONTRIBUTING.md, Profile 1) — the same mechanism as any other catalog. A promotion path is real and planned, not yet built: a function proving itself out in a federated or private catalog first, then getting adopted into std under stricter review, same hash carried through. Worth knowing which one you’re using today — direct PR is what actually happens right now. Either way, publication is permanent by spec: catalogs are append-only, so an entry’s hash and its name resolution survive forever — a bug fix is a new entry, never a rewrite of the old one (see 05).

11.How do I know which functions already exist in the catalog?

This is where the “you don’t” pattern ends for good. Discovery is the actual job.

You don’t memorise the catalog, and you’re not meant to. Holding every function name in your head is the machine’s job, not yours. What you do instead is describe the shape of the hole you need filled, and let search find what fits.

Types are the index. A function’s signature — plus its contract, the laws and properties stored alongside it — is how you locate it. It’s the move you’d make with Hoogle in Haskell, with two differences: the search runs over every catalog you’ve loaded — public standard, company-private, licensed — as one uniform hash-keyed space rather than a pile of per-library APIs, and a match comes back as a name pinned to a hash. For anything written in PFCL, that hash is minted from the canonical source — same text, same hash, in any catalog, forever — not a versioned package that can change under you.

Concretely: you have a List<Float> and need a Float. That one type shape cuts the whole catalog down to a handful of candidates — math.mean among them — and their descriptions and tags settle which one you meant. There are three ways to reach a function. Name it, if you already know it. Search by what it does — every entry carries a description and tags in plain YAML, so ordinary text search over the catalog finds behavior. Or describe it by type and let search return candidates — today that’s pfcl --find '<type>' at the command line. The catalog is deliberately fine-grained too: every reusable intermediate step is its own named, hashed entry rather than something buried inside a bigger function, which is exactly what makes the building blocks findable. list.fold_left is a first-class entry, not a private helper — which is why dozens of other functions surface as compositions of it.

All three roads want to live in the editor, and that’s the planned official route: a language server built over the same parser, resolver, and loaded catalogs — so completion is a catalog query, hover shows an entry’s contract and examples, and an unresolved name is a squiggle with the correction attached. VS Code will be the reference client; the protocol means any editor gets the same server. Until that ships, the REPL is the discovery surface.

12.So how do I break a problem into small functions?

Mostly you diff two types and follow the gap.

Decomposition here is closer to mechanical than creative. Look at what you have (the input type) and what you want (the output type). Each type constructor you need to introduce or eliminate is a function-shaped hole with a knowable signature. Order the holes by which one’s output feeds the next. Then, for each hole: search the catalog; if something fits, wire it in; if nothing does, decompose that hole further.

The good part is what happens when the catalog doesn’t have what you need — but it’s a step you do, not something the system hands you automatically (that part isn’t built yet). Once you’ve diffed the types and know the shape of the hole, check it against the catalog directly: pfcl --find '<the type you derived>'. A real match means you’re done — wire it in. No match means the gap is confirmed, and you’re left holding something concrete: the exact signature of what to build next, derived by you, verified absent by the catalog itself rather than assumed. Still the precise opposite of an assistant confidently calling a plausible-looking function that turns out not to exist — the type you’re holding is checked, not guessed — just a manual step, not an automatic one.

13.Can an LLM write this language? There’s no training data.

Not from memory. Which turns out to be the wrong thing to want anyway.

PFCL is a low-resource language by construction — a small frozen grammar and essentially no public corpus. A model can’t autocomplete it from vibes. And a language whose whole point is that every named function must actually exist shouldn’t want vibes; it wants a model working from the reference.

The working method is a three-part context package. One: the grammar, as EBNF derived from and checked against the real parser — not paraphrased from documentation. Two: the peculiarities doc, PFCL_LLM_CONTEXT.md, which ships in the repo and lists the things that surprise a model exactly once (no infix operators, no underscore lambda parameter, and so on). Three: the catalog YAML for the namespace the task lives in — list/ for a list task, string/ for a string task — with every signature, description, and example, at the scale of a reference card rather than a phone book.

The deliberate choice is that third part. Handing over the entire catalog would work, in the way an answer key works: the model would be searching a big document, not writing the language. The namespace slice keeps it a real test — the model still has to navigate a real reference and compose real names, the same way you would.

All three pieces are public, so you don’t have to take this on faith: assemble the package, hand it to whatever model you use, and ask for a function. Whatever comes back either loads — typechecks, resolves every name to a hash, passes the cycle check — or fails at load with a precise reason. There is no “looks plausible” middle ground for the model to hide in, and that property does more for LLM-assisted programming than any volume of training data.

You’ve probably spotted the pattern

Compiler? A loader. Debugger? No. Package manager? No. Loops, exceptions, imports, I/O, mocks? No, no, no, no, no.

That isn’t the language being unfinished. It’s the whole idea. Each of those tools exists to manage a specific kind of complexity — hidden state, competing versions, tangled control flow, side effects buried mid-function. PFCL removes those at the source, and once the disease is gone the medicine looks like a missing feature.

What’s left is a catalog of small functions that are just their source text, composed into larger functions that are still just their source text, plus thin edges that hand a list of commands to whatever host is willing to run them. The verbs sort themselves out. You compose functions — and what composition produces is another function, another catalog entry. You assemble programs — parts, plus a thin edge, plus a host. And you write only what’s genuinely new, once, at the moment it enters a catalog; from then on it’s a part, shared exactly as widely as the catalog itself: public on Codeberg, private to your company, or licensed to whoever pays for it.

Fine print: the PFCL language, the type system, and the content-addressed catalog are real and public — a tree-walking interpreter in Rust, plus a YAML catalog you can read with sha256sum. Commands only do anything once a host reads them — any host that implements the command vocabulary. The jokes above are all load-bearing — every “you don’t” is a real property, not a shrug.

References

  1. PFCL. Standalone repository: interpreter, catalog, REPL. codeberg.org/vickov/pfcl.
  2. PFCL. Contributing to the standard catalog. CONTRIBUTING.md on Codeberg.
  3. PFCL. Context package for LLM authoring. PFCL_LLM_CONTEXT.md on Codeberg.
  4. PFCL. Catalog: content addressing, admission, and loader semantics. /catalog.
  5. PFCL. Totality: the termination guarantee, stated formally. /totality.
  6. PFCL. Effects as data: the command protocol. /effects-as-data.