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.
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
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.
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 Functionsfx 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 Actionsio 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 ioSometimes 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.
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.
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.
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 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
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.
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
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")))
io From fx(io load [] (pure 1))
(fx bad []
(load))
Fix:
(io load [] (pure 1))
(io main []
(let value (<- (load)))
value)
<- Outside io(let value (<- (load)))
Fix:
(io load [] (pure 1))
(io main []
(let value (<- (load)))
value)
(import [slugify] from "./slug.js")
(fx clean [value]
(slugify value))
Fix:
(import [(fx slugify)] from "./slug.js")
(fx clean [value]
(slugify value))
(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"))
(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")))
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.
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.