Why I made the module system a hash map
Published June 2026 · PFCL on Codeberg
Most languages treat the module system as a namespace manager: write import foo, the compiler resolves names, code executes. PFCL has no import statement, no package manager, no module path. Every function lives in a content-addressed catalog instead — its identity is the SHA-256 hash of its typed source body, and names are aliases on top of that hash.
Historical Lineage
Joe Armstrong, the creator of Erlang, described this in a 2011 talk, "The Mess We're In":
"We don't have open source projects. We have an open source KV database of all functions, and we ask it for the functions we need."
Armstrong's vision: query a global database of functions by their properties instead of managing source files organized by project. Identity is stable, version hell is impossible, every function is discoverable by type.
PFCL implements Armstrong's vision with three additions: a type system (Hindley-Milner), purity enforcement (no escape hatches), and a concrete wire format for effects (JSON-like Command records instead of monads).
What Problem Does This Solve?
The module system in most languages is a governance layer designed to prevent name collisions and manage scale. But it brings friction:
- Package manager overhead. npm, pip, cargo, Maven. Each imposes versioning strategy, dependency resolution, and registry politics. You can't use a library without negotiating with a central authority.
- Dependency hell. Version constraints, semantic versioning disagreements, transitive dependency conflicts. The "diamond problem" is real.
- Implicit coupling. You don't know what version of a dependency you're calling until build time. The caller references a name; the linker finds a version. If the version changes, behavior changes silently.
Content addressing solves all three by making identity permanent and verifiable.
How It Works
A PFCL function is defined in YAML:
name: math.mean
type: List<Float> -> Maybe<Float>
body: |
\(xs).
match list.is_empty(xs) with
| true -> nothing
| false ->
just(float.divide(
list.fold_left(\(acc, x). float.add(acc, x), 0.0, xs),
int.to_float(list.length(xs))))
description:
short: "Arithmetic mean. Nothing for empty input."
The hash is computed from the typed source body alone. Strip the name and description, take the type and body text, run SHA-256, and you have the function's identity:
$ echo -n 'List<Float> -> Maybe<Float> | \(xs). match list.is_empty(xs) ...' | sha256sum
a3f9b2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1 -
Load the catalog with --catalog ./std, and at startup the runtime resolves all names to their hashes. Program references use names (human-readable), but internal execution uses hashes (immutable).
Why This Beats a Package Manager
| Property | npm/pip/cargo | Content-Addressed Catalog |
|---|---|---|
| Identity stability | No — versions change, yanks happen | Yes — hash never changes |
| Verify without tooling | No — need the ecosystem tools | Yes — sha256sum works |
| Dependency resolution | Complex — version constraints, conflict resolution | None — name → hash is deterministic at load time |
| Central authority | Required — npm, PyPI, crates.io | Optional — catalog is files in git |
| Append-only guarantee | No — packages get removed, yanked | Yes — by spec, every hash is resolvable forever |
Effects Are Different Too
In Haskell or ML, side effects are baked into the type system: IO a, ref, monads. In PFCL, functions return lists of plain records:
type Command = {kind: String, ...fields}
main : List<String> -> List<Command>
main = \(args).
[{kind: "stdout", text: "Hello, world!"}]
The runtime reads the list and executes. A command is not an IO monad value — it's a record with a kind field that identifies what action to take.
This is a wire format, not a language interface. Any executor in any language that can deserialize a list of records is a valid PFCL runtime. Python, Go, JavaScript, Rust — all can interpret the same command list. This is genuinely portable.
Append-Only Forever
By spec, once a catalog entry is published, its hash is permanent:
- Bug fix? Add a new entry:
math.mean_v2with the corrected body. The old entry stays forever. - Breaking change? New entry. Callers migrate when ready. No forced upgrades.
- Deprecation? Tag it with
#deprecatedin YAML. The hash still resolves.
This eliminates the "diamond dependency" problem. Two versions of the same function cannot coexist in the same hash namespace — if the bodies differ, they are different functions. Identity is content, not conventional.
Comparison with Unison
Unison is the closest existing language. It also uses content addressing. But the tradeoffs differ:
| Dimension | Unison | PFCL |
|---|---|---|
| Hash computed from | AST (alpha-equivalent) | Typed source text |
| Verify hash without tooling | Unison tooling required | sha256sum works everywhere |
| Catalog ownership | UCM (Unison tool) owns it | You own it — YAML in git |
| Catalog readable without tooling | Go through UCM | cat, diff, grep, git blame |
| Executor | Unison runtime only | Any process that reads JSON records |
| Totality enforced | No | Yes — cycle check + fix guard |
Unison's choice to hash the AST is mathematically elegant — renames don't change identity. PFCL's choice to hash the source text is more auditable: sha256sum verifies it on any Unix system, no tooling required.
Current State
PFCL ships as:
- A tree-walking interpreter in Rust (~17K LOC across 7 crates)
- ~160 standard-library functions across list, string, math, JSON, cryptography
- A REPL, a file runner, and a
--catalogflag for overlaying catalogs - Cycle detection at load time;
fixcombinator for recursion with a 100k-iteration guard - Hindley-Milner type inference
Planned improvements:
- Cranelift JIT: native code compilation for performance
- Catalog retirement: post-launch name → hash rebinding (update all callers transparently)
- Hash pinning: call a function by its exact hash instead of name, opting out of all updates
Limitations
This model doesn't solve everything:
- Doc browser. No parity with Unison's click-and-navigate browser. PFCL has
:inspectin the REPL, covering basic cases. Richer browser is future work. - Catalog bloat. Append-only means old entries never disappear; growth is monotonic. Matters at production scale — millions of functions.
- Partiality. Documented, not eliminated —
list.head([])panics. The spec records known holes. Mechanical enforcement is roadmap, not shipped.
The Pitch. "Define a function — its hash is yours for life."
No package manager. No central authority. No dependency hell. Just a catalog of pure functions, identified by SHA-256, loaded at startup, verified with tools that ship on your machine.