Pure Mode HQL

Pure Mode HQL is the opt-in discipline layer of HQL. It lets a developer write code with explicit purity and IO boundaries while keeping normal HQL practical, Lisp-shaped, and native to the JavaScript and TypeScript ecosystem.

This document is a detailed companion to SPEC.md. The compact language authority remains SPEC.md; this file explains the pure-mode model, tradeoffs, and examples.

Short Version

Pure Mode HQL separates two kinds of work:

Pure calculation                     World action
----------------                    -------------------------
same input -> same output            talks to outside world
no hidden print/fetch/file/time      print, fetch, file, time
allowed inside fx                    must live in io

In HQL:

(fx format-user [user]
  (+ user.name " <" user.email ">"))

(io load-user [id]
  (pure {name: "Ada", email: (+ "ada+" id "@example.test")}))

(io main []
  (let user (<- (load-user 7)))
  (format-user user))

(main)

Read it as:

fx      pure checked function
io      function that may perform world actions
pure    lift a plain value into the io lane
<-      unwrap an io value, only inside io

Normal HQL stays available. Pure Mode HQL is additive.

normal HQL
  quick scripting, apps, npm, JS interop, ordinary fn

pure mode HQL
  fx/io separation, checked enum/match, protocol/extension checks,
  declared JS/TS/npm boundary when compiling in pure mode

Why This Exists

Without a pure lane, code can look like a plain calculation while secretly touching the world.

(fn price-label [price]
  (print "debug")
  (+ "$" price))

That is convenient, but it is not pure. It prints.

Pure Mode HQL makes the decision visible:

(fx price-label [price]
  (+ "$" price))

(io main []
  (print (price-label 10)))

If an fx function tries to print, fetch, mutate managed state, or call an effectful function, the compiler rejects it.

(fx bad [price]
  (print "debug")
  (+ "$" price))

Conceptually:

  (fx bad ...)
        |
        v
compiler inspects body
        |
print is a world action
        |
        v
     reject

The point is not academic purity for its own sake. The point is that pure code is easier to test, cache, move, parallelize, review, and trust.

The Two-Lane Model

HQL has two useful lanes:

+-----------------------------+------------------------------+
| Normal lane                  | Pure lane                    |
+-----------------------------+------------------------------+
| fn                           | fx + io                      |
| convenient                   | explicit                     |
| JS/npm imports work normally | JS/npm imports need contract |
| good for apps and scripts    | good for domain core         |
| effects allowed             | effects visible              |
+-----------------------------+------------------------------+

The lanes can coexist in one codebase.

app shell / adapters / UI / CLI / npm glue
  normal fn and io are fine

domain core / business rules / transforms / validators
  prefer fx

ASCII view:

                     application
                         |
                         v
+--------------------- io shell ----------------------+
| fetch, print, file, database, DOM, npm effects      |
|                                                     |
|   +---------------- pure core ------------------+   |
|   | fx functions, enum, match, protocols        |   |
|   | no hidden world actions                     |   |
|   +---------------------------------------------+   |
+-----------------------------------------------------+

Pure Mode HQL does not require every file to be pure. It lets the pure core be strict while the shell stays practical.

fx: Pure Checked Functions

fx declares that a function must be pure.

(fx add [a b]
  (+ a b))

Allowed inside fx:

(fx price-label [price]
  (+ "$" price))

(enum Result
  (case Ok value)
  (case Err message))

(fx render-result [result]
  (match result
    (case (Ok value) value)
    (case (Err message) (+ "error: " message))))

Rejected inside fx:

(fx bad-print [x]
  (print x)
  x)

(fx bad-io []
  (<- (load-user 1)))

(fx bad-call []
  (load-user 1))

(fx bad-mutation [cell]
  (reset! cell 1))

Plain explanation:

fx means:
  "Compiler, check that this function is only calculation."

If the checker cannot prove a call is pure, it fails closed.
Unknown is not treated as safe.

That last rule matters. In hard code, "I do not know" is not pure.

known pure     -> allowed
known io       -> rejected in fx
known unsafe   -> rejected in fx
unknown        -> rejected in fx

io: World Actions

io declares that a function may perform world actions.

(io load-user [id]
  (fetch (+ "/api/users/" id)))

An io function returns an action-like value. Use <- to unwrap it inside another io function:

(io load-user [id]
  (pure {name: "Ada", id: id}))

(io main []
  (let user (<- (load-user 1)))
  (print user))

<- is intentionally restricted:

+-------------------+-----------+
| Location          | <- allowed |
+-------------------+-----------+
| inside io         | yes       |
| inside fx         | no        |
| top level         | no        |
| nested non-io fn  | no        |
+-------------------+-----------+

Rejected:

(let user (<- (load-user 1)))

(fx bad []
  (<- (load-user 1)))

(io main []
  (let nested (fn [] (<- (load-user 1))))
  (nested))

The nested case matters because the body of a plain nested fn is not itself an io body. The permission does not leak through function boundaries.

pure: Lifting Plain Values Into io

Sometimes an io function wants to return an already-computed value.

(io load-user [id]
  (pure {name: "Ada", id: id}))

Then callers unwrap it normally:

(io load-user [id]
  (pure {name: "Ada", id: id}))

(io main []
  (let user (<- (load-user 7)))
  user)

Plain model:

plain value:
  42

io value:
  action that will produce 42

pure:
  wrap plain value as an io value

The important part is the boundary: callers use <- only inside io, and a plain value can be lifted into that lane with pure.

Enum And Exhaustive Match

Pure Mode HQL uses enum for closed choices.

(enum Result
  (case Ok value)
  (case Err message))

This means:

Result is one of:
  Ok(value)
  Err(message)

Use match to handle the cases. Case patterns that carry a payload use the qualified constructor form (Type.Case binding ...):

(fx render [result]
  (match result
    (case (Result.Ok value) value)
    (case (Result.Err message) (+ "error: " message))
    (default "unmatched")))

Two behaviors depend on whether the compiler is in pure mode (--pure):

normal mode (e.g. hql run):
  payload case patterns must be qualified: (Result.Ok value)
  no exhaustiveness check; an fx match needs a `default`
  (the implicit no-match fallback throws, and fx rejects Throws)

pure mode (--pure):
  bare case names resolve to their enum type: (Ok value)
  match over known `enum` is checked for exhaustiveness
  an exhaustive match needs no `default` (one is supplied automatically)

In pure mode, a match over known enum must cover all cases unless there is a default.

(enum Result
  (case Ok value)
  (case Err message))

(fx bad-render [result]
  (match result
    (case (Ok value) value)))

(bad-render (Result.Ok "Ada"))

Under --pure the checker rejects this with:

match over 'Result' is not exhaustive; missing Err

ASCII view:

Result cases: Ok, Err
matched:      Ok
missing:      Err  -> reject

This exhaustiveness check runs only under --pure. In normal mode the same fx match is instead rejected because its implicit no-match fallback throws, and fx does not allow the Throws effect; add a default (or handle every case) so the fallback never runs.

Case names must be unique inside one enum declaration:

(enum Result
  (case Ok value)
  (case Ok message))

Rejected (in any mode):

enum 'Result' has duplicate case 'Ok'

If two different enum types reuse the same case name, the bare name is ambiguous. In pure mode the compiler rejects it; use the qualified form instead:

(enum UserResult
  (case Ok value)
  (case Err message))

(enum OrderResult
  (case Ok value)
  (case Missing message))

(fx render [result]
  (match result
    (case (Ok value) value)
    (default "fallback")))

Rejected under --pure:

ambiguous enum case 'Ok'; use a qualified constructor

Use the explicit constructor:

(enum UserResult
  (case Ok value)
  (case Err message))

(enum OrderResult
  (case Ok value)
  (case Missing message))

(fx render [result]
  (match result
    (case (UserResult.Ok value) value)
    (case (UserResult.Err message) (+ "error: " message))))

(render (UserResult.Ok "Ada"))

ASCII view:

bare Ok
  |
  +-- UserResult.Ok
  |
  +-- OrderResult.Ok
  |
  v
reject; choose a qualified name

Unknown constructors are rejected under --pure:

(enum Result
  (case Ok value)
  (case Err message))

(fx render [result]
  (match result
    (case (Result.Ok value) value)
    (case (Result.Pending value) value)))

(render (Result.Ok "Ada"))

Pending is not a case of Result, so pure mode reports:

match over 'Result' has unknown case Pending

Guarded cases do not count as full coverage (pure-mode exhaustiveness):

(enum Result
  (case Ok value)
  (case Err message))

(fx render [result]
  (match result
    (case (Ok value) (if false) value)
    (case (Err message) message)))

(render (Result.Err "missing"))

The Ok branch has a condition. It might not run, so it cannot prove that Ok is covered for every value, and --pure requires a default.

Protocol And Extension

Pure Mode HQL uses one public open-polymorphism surface:

(protocol Display
  (show [value]))

(extension Display "User"
  (show [user] user.name))

Read it as:

protocol   what behavior must exist
extension  how one dispatch key implements that behavior

Calling a protocol method:

(protocol Display
  (show [value]))

(extension Display "User"
  (show [user] user.name))

(Display.show {"type": "User", "name": "Ada"})

Result:

"Ada"

The dispatch key is found from the value:

1. :type
2. "type"
3. constructor
4. JavaScript typeof

So this value dispatches to "User":

{"type": "User", "name": "Ada"}

In pure mode (--pure), an extension must match the protocol; these shape checks (missing method, unknown method, duplicate extension method) are not enforced in normal mode. A duplicate method inside a protocol declaration is rejected in any mode.

Rejected because it misses label:

(protocol Display
  (show [value])
  (label [value]))

(extension Display "User"
  (show [user] user.name))

Rejected because it adds an unknown method:

(protocol Display
  (show [value]))

(extension Display "User"
  (show [user] user.name)
  (debug [user] user.id))

Rejected because the protocol repeats the same method:

(protocol Display
  (show [value])
  (show [value]))

Rejected because the extension repeats the same method:

(protocol Display
  (show [value]))

(extension Display "User"
  (show [user] user.name)
  (show [user] user.id))

The public names are intentionally simple:

contract declaration:
  protocol

contract implementation:
  extension

Generated TypeScript may still use the TypeScript keyword interface internally. HQL source and HQL diagnostics should say protocol.

JS, TS, And npm Boundaries

Normal HQL keeps frictionless imports:

(import [value] from "./plain.js")
(value)

Pure mode requires imported JS/TS/npm functions to declare their contract:

(import [(fx slugify)] from "./slug.js")
(import [(io load)] from "./loader.js")
(import [(unsafe legacy)] from "./legacy.js")

Meaning:

fx      imported function is pure enough to call from fx
io      imported function is effectful; unwrap from io
unsafe  imported function is unknown or dangerous; not allowed in fx

Example:

(import [(fx slugify)] from "./slug.js")

(fx clean [value]
  (slugify value))

Allowed, because slugify is declared as fx.

(import [(io load)] from "./loader.js")

(fx bad []
  (load))

Rejected, because load is io.

Use io imports from io code:

(import [(io load)] from "./loader.js")

(io main []
  (let value (<- (load)))
  (+ value 1))

npm default import example:

(import [(io default as fetch)] from "npm:node-fetch")

(io load [url]
  (<- (fetch url)))

Current HQL intentionally keeps the contract small. Users do not write detailed effect lists at the import site.

not the current model:
  imported effect = network + console + throws + time

The current hard boundary is:

fx / io / unsafe

That is easier to read, easier to teach, and still catches the important mistake: hiding world actions inside pure code.

Pure Mode

Pure mode is the compiler mode that tightens the hard boundary.

CLI:

hql compile app.hql -o app.js --pure

Programmatic option:

pureMode: true

Pure mode is where JS/TS/npm imports must declare fx, io, or unsafe. Normal mode keeps uncontracted JS imports compatible.

normal mode:
  (import [value] from "./plain.js")  allowed

pure mode:
  (import [value] from "./plain.js")  rejected
  (import [(fx value)] from "./plain.js") allowed

Pure mode is also where several other checks switch on. Outside pure mode the fx/io/<- effect discipline still applies, but these extras do not:

only enforced under --pure:
  JS/TS/npm imports must declare fx / io / unsafe
  match over known enum is checked for exhaustiveness
  bare enum-case patterns (Ok value) resolve to their type
  ambiguous bare case names are rejected
  extension shape: must implement exactly the protocol methods

Outside pure mode, write payload case patterns in the qualified form (Result.Ok value), and give an fx match a default (or full coverage) so its no-match fallback never throws.

Use pure mode when you want strict proof for a module or application path. See examples/pure-mode-user-service/ for a small checked multi-file example.

Pure mode applies to imported HQL dependencies too. If main.hql imports domain.hql, then JavaScript, TypeScript, and npm imports inside domain.hql must also declare fx, io, or unsafe.

main.hql --pure
  |
  +-- imports domain.hql
        |
        +-- imports slug.js
              |
              v
        must be declared: (fx slugify), (io load), or (unsafe legacy)

This prevents a dependency from hiding an unchecked JS boundary behind an apparently pure HQL API.

The boundary is checked before JS or TS modules are loaded. Bad imports fail early instead of letting external code or tooling run first.

Rejected in pure mode:

(import "./setup.js")             ; side-effect-only import
(import math from "./math.js")    ; bare import may hide a namespace/default
(import [slugify] from "./slug.js")

Accepted in pure mode:

(import [(fx slugify as clean)] from "./slug.js")
(import [(io default as fetch)] from "npm:node-fetch")
(import [(unsafe legacy)] from "./legacy.js")

Aliases and default imports are fine, but the contract must stay attached to the binding. The same rule applies through multiple HQL files:

main.hql --pure
  imports facade.hql
    imports domain.hql
      imports slug.js without fx/io/unsafe

result:
  compile fails at the missing slug.js contract

Relationship To Existing Effect Systems

If you already know Haskell IO or Scala effect libraries, the closest HQL idea is visibility: pure helpers and world actions are kept in different lanes. The difference is important. HQL does not make the whole language pure by default, and it does not implement Haskell or Scala's type systems. Pure Mode HQL is a checked lane inside a practical JS/TS language.

Checked Boundaries

When code is compiled with --pure and its foreign imports declare fx, io, or unsafe, the compiler checks these boundaries:

fx function:
  no known world action
  no io call
  no <- unwrap
  no managed mutation
  unknown calls fail closed

io function:
  may call world actions
  may unwrap io values with <-
  may call fx helpers

enum/match:
  known enum cases are checked for coverage in pure mode

protocol/extension:
  extension must implement known protocol methods
  extension must not invent unknown protocol methods

JS/TS/npm boundary:
  pure mode requires fx/io/unsafe import contracts
  imported HQL dependencies inherit the same pure-mode import checks

What Pure Mode HQL Does Not Claim

Pure Mode HQL does not make all of HQL Haskell.

It does not require:

lazy-by-default whole language
Hindley-Milner type inference everywhere
typeclasses as the only polymorphism mechanism
currying everywhere
no JS values
no mutation anywhere
no ordinary fn
no untyped JS/npm access in normal mode

That would be a different language.

Pure Mode HQL also does not prove that a JS/npm implementation is pure. A declared import contract is a boundary claim written by the HQL author. If that claim is wrong, the compiler still trusts the declared boundary.

Open protocol dispatch is also not treated as automatically pure inside fx. An extension can be checked for shape, but dispatch is still runtime/open dispatch. Keep pure business calculations in direct fx helpers. Use protocol dispatch from the io/fn shell unless the compiler has a known pure path.

The design response is:

normal mode:
  keep interop easy

pure mode:
  force the boundary to be explicit

code review:
  treat fx/io/unsafe imports as important API facts

A common shape is:

src/
  domain/
    pricing.hql        mostly fx
    validation.hql     mostly fx
    result.hql         enum + match
    display.hql        protocol

  adapters/
    http.hql           io, fetch
    database.hql       io
    filesystem.hql     io

  app.hql              io shell

Flow:

    io shell
       |
       v
 calls pure core
       |
       v
returns plain values
       |
       v
    io shell
 prints / fetches / saves

Keep the domain core boring:

(fx calculate-total [prices]
  (reduce + 0 prices))

(fx valid-email? [email]
  (!== email ""))

Keep world access at the edge:

(io load-user [id]
  (<- (fetch (+ "/api/users/" id))))

(io save-user [user]
  (<- (fetch "/api/users" {method: "POST", body: (JSON.stringify user)})))

Compose at the shell:

(fx valid-email? [email]
  (!== email ""))

(io load-user [id]
  (pure {email: "ada@example.test"}))

(io save-user [user]
  (<- (fetch "/api/users" {method: "POST", body: (JSON.stringify user)})))

(io main []
  (let user (<- (load-user 1)))
  (if (valid-email? user.email)
    (<- (save-user user))
    (print "missing email")))

Common Mistakes

Mistake: Calling io From fx

(io load [] (pure 1))

(fx bad []
  (load))

Fix:

(io load [] (pure 1))

(io main []
  (let value (<- (load)))
  value)

Mistake: Using <- Outside io

(let value (<- (load)))

Fix:

(io load [] (pure 1))

(io main []
  (let value (<- (load)))
  value)

Mistake: Forgetting An Import Contract In Pure Mode

(import [slugify] from "./slug.js")

(fx clean [value]
  (slugify value))

Fix:

(import [(fx slugify)] from "./slug.js")

(fx clean [value]
  (slugify value))

Mistake: Protocol Extension Does Not Match Protocol

(protocol Display
  (show [value])
  (label [value]))

(extension Display "User"
  (show [user] user.name))

Fix:

(protocol Display
  (show [value])
  (label [value]))

(extension Display "User"
  (show [user] user.name)
  (label [user] "user"))

Mistake: Non-Exhaustive Match

(enum Result
  (case Ok value)
  (case Err message))

(fx render [result]
  (match result
    (case (Result.Ok value) value)))

Fix (handle every case, or add a default):

(fx render [result]
  (match result
    (case (Result.Ok value) value)
    (case (Result.Err message) (+ "error: " message))
    (default "unmatched")))

Design Principles

Pure Mode HQL follows these rules:

1. Additive, not mandatory
   Normal HQL remains practical.

2. Simple syntax first
   Prefer fx/io/unsafe over verbose effect lists.

3. Pure core, effectful shell
   Put business rules in fx; put world access in io.

4. Explicit boundary
   JS/TS/npm interop stays native, but pure mode requires contracts.

5. Fail closed
   Unknown is not pure.

6. One public contract vocabulary
   Use protocol and extension, not many names for the same idea.

Mental Model

Think of io as a recipe.

plain value:
  sandwich

io value:
  recipe for making a sandwich

<-:
  run the recipe and bind the sandwich

fx:
  cannot run recipes

io:
  can run recipes

Diagram:

(fx format-user ...)
        |
        v
 pure calculation only

(io main ...)
        |
        +--> (<- (load-user 1))   run recipe
        |
        +--> (format-user user)   call pure helper
        |
        +--> (print ...)          world action

This is the whole heart of the feature.

Pure Mode HQL is not "make everything pure forever." It is:

When I choose the pure lane, reject hidden effects that the HQL compiler can see
and require explicit contracts for foreign imports.