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:

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

Propertynpm/pip/cargoContent-Addressed Catalog
Identity stabilityNo — versions change, yanks happenYes — hash never changes
Verify without toolingNo — need the ecosystem toolsYes — sha256sum works
Dependency resolutionComplex — version constraints, conflict resolutionNone — name → hash is deterministic at load time
Central authorityRequired — npm, PyPI, crates.ioOptional — catalog is files in git
Append-only guaranteeNo — packages get removed, yankedYes — 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:

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:

DimensionUnisonPFCL
Hash computed fromAST (alpha-equivalent)Typed source text
Verify hash without toolingUnison tooling requiredsha256sum works everywhere
Catalog ownershipUCM (Unison tool) owns itYou own it — YAML in git
Catalog readable without toolingGo through UCMcat, diff, grep, git blame
ExecutorUnison runtime onlyAny process that reads JSON records
Totality enforcedNoYes — 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:

Planned improvements:

Limitations

This model doesn't solve everything:

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.