HQL is a Lisp-shaped language that compiles to JavaScript and TypeScript. This reference describes the current pre-release syntax. The language authority is SPEC.md; this file expands that specification with examples.
This document is the syntax SSOT. For cross-feature expression and return semantics, see expression-foundation.md. For the opt-in pure/IO discipline layer, see PURE-MODE.md.
HQL uses a hybrid reader:
(...) is S-expression code and code-data. It uses spaces, never commas.[], {}, and #[] are data forms. Spaces choose HQL persistent data;
commas or JS property colons choose JS-native data.[] is the empty JS array. Use (vector) for an empty persistent vector.{} is the empty persistent map. Use (js-obj) for an empty native JS
object. HQL reader
|
+-----------+-----------+
| | |
(...) [...] {...} / #[...]
code data data
spaces space/comma space/comma
|
+-- quote changes evaluation, not punctuation
'(a b c) is still parenthesized S-expression syntax
+------------+----------------------------+-----------+----------------------+
| Delimiter | Meaning | Separator | Example |
+------------+----------------------------+-----------+----------------------+
| (...) | code form / code-data list | space | (+ 1 2) |
| [...] | persistent vector / JS Array | space/comma | [1 2 3] / [1, 2, 3] / [] |
| {...} | persistent map / JS object | space/colon | {:name "Ada"} / {name: "Ada"} |
| #[...] | persistent set / JS Set | space/comma | #[1 2 3] / #[1, 2, 3] |
| '(...) | quoted code-data list | space | '(a b c) |
+------------+----------------------------+-----------+----------------------+
The separator chooses the lane for bracketed data.
(+ 1 2 3)
[1 2 3]
[1, 2, 3]
[]
[1]
[1,]
(vector)
{:name "Ada"}
{name: "Ada", age: 37}
#[1 2 3]
#[1, 2, 3]
'(1 2 3)
(type Handler (value:number, opts:{mode:string}) => string)
Rejected:
(+ 1, 2)
{name: "Ada" age: 37}
#[1 2, 3]
#{1 2 3}
'(1, 2)
Single-item vectors are a real ambiguity: [1] has no separator, so it is the
persistent vector lane. Use [1,] for a one-item JS array.
42
"hello"
true
false
nil
[1 2 3]
[1, 2, 3]
{}
{:name "Ada"}
{"name" "Ada"}
{name: "Ada", age: 37}
{"name": "Ada", "age": 37}
(js-obj)
#[1 2 3]
#[1, 2, 3]
Objects are data only. They are not named parameters:
(fn inspect [value] value)
(inspect {name: "Ada"})
pr-str and read-string use the same delimiter rule for code-data strings:
(pr-str (quote (a b c))) // "(a b c)"
(pr-str (quote [a b c])) // "[a b c]"
(pr-str (quote {})) // "{}"
(pr-str (quote {kind: "x"})) // "{\"kind\": \"x\"}"
(pr-str (quote #[1 2])) // "#[1 2]"
(read-string "(a b c)") // parenthesized code list
(read-string "[a b c]") // vector code data
(read-string "{}") // persistent map code data
(read-string "{\"kind\": \"x\"}") // map code data
(let x 10)
(var count 0)
(let pi 3.14159)
(def answer 42)
(let [head ...tail] [1, 2, 3])
(let {name: n, age: a} {name: "Ada", age: 37})
The named declaration shape is:
(fn name [param: Type = default other = value]
body)
defn is an alias for the named fn declaration.
Examples:
(fn add [a b]
(+ a b))
(defn add2 [a b]
(+ a b))
(fn connect [host: string = "localhost" port: number = 8080]
(+ host ":" (.toString port)))
A function expression omits the name. => is the anonymous shorthand. Both use
the same [] parameter list:
(let double (fn [x] (* x 2)))
(let triple (=> [x] (* x 3)))
Calls can be positional:
(fn connect [host: string = "localhost" port: number = 8080]
(+ host ":" (.toString port)))
(connect "api.com" 443)
Or named, for known lexical HQL fn declarations:
(fn connect [host: string = "localhost" port: number = 8080]
(+ host ":" (.toString port)))
(connect port: 443 host: "api.com")
Named labels are rejected for unknown calls, imported/host calls, constructors, method calls, rest-parameter functions, and pattern-parameter functions. Positional and named arguments do not mix in one call.
Parameter items are structured list items:
name
name: Type
name = default
name: Type = default
...rest
...rest: Type
(class Counter
(var value:number)
(constructor [initial: number = 0]
(set! this.value initial))
(fn inc [by: number = 1]
(set! this.value (+ this.value by))
this.value))
(let c (new Counter 10))
(c.inc 5)
(let x 0)
(let state "open")
(let value 42)
(if (> x 0) "positive" "other")
(cond
((< x 0) "negative")
((=== x 0) "zero")
(else "positive"))
(switch state
(case "open" 1)
(default 0))
(match value
(case 0 "zero")
(case 42 "the answer")
(default "other"))
do groups expressions and evaluates to its last expression:
(do
(print "step")
42)
Errors use try/catch with throw:
(try
(throw (Error "boom"))
(catch e "caught"))
Loop and iteration binding vectors use spaces:
(loop [i 0 total 0]
(if (< i 5)
(recur (+ i 1) (+ total i))
total))
(for [i 3]
(print i))
(for [i from: 0 to: 10 by: 2]
(print i))
(for-of [item [1, 2, 3]]
(print item))
For async iterables, use the same binding shape with for-await-of:
(for-await-of [event events]
(print event))
(type Id number)
(type Status "open" | "done")
(type Pair [number, string])
(type Payload {id:number, name:string})
(type Handler (value:number, opts:{mode:string}, ...flags:boolean[]) => string[])
(fn parse [input: string] -> number
(Number input))
(protocol Store<T> [
(prop value T)
(fn save [item:T label?:string] -> T)
(call [item:T] -> boolean)
])
(import [parse stringify] from "@hql/json")
(parse "{\"ok\":true}")
(stringify {ok: true})
(export [parse stringify])
This section is the HQL package-publishing SSOT. HQL source is the authoring format. Published packages are ordinary ESM packages for the JavaScript ecosystem.
HQL package source
|
v
+-----------------------+-----------------------+
| |
check pack
| |
| +---------------------+---------------------+
| | | |
v v v v
validate source dist/esm/*.js dist/jsr/esm/*.js dist/types/*.d.ts
validate exports npm package.json jsr.json/deno.json README/LICENSE
validate targets
|
v
publish
|
+------------------> JSR
|
+------------------> npm
Consumers never need HQL to use the published artifact.
They import standard ESM.
The semantic boundary is:
S = (hql.json or inferred defaults, HQL sources, JS/TS imports)
A = pack(S)
publish(S, target) = upload(target, A[target])
pack is pure packaging from the user's point of view: it writes local
artifacts and performs validation. publish is the same package resolution and
packaging pipeline followed by a registry upload.
The package root is resolved from the command path:
| Input | Meaning |
|---|---|
hql pack | Use the current directory |
hql pack ./lib | Use ./lib as the package root |
hql pack ./hello.hql | Package a single HQL file |
hql pack ~/Downloads/hello.hql | Package that file without requiring a project directory |
For a directory package, HQL looks for hql.json. If there is no config, it
uses zero-config defaults:
root/
mod.hql preferred entry
index.hql fallback entry
main.hql fallback entry
If more than one entry candidate exists without config, the package is ambiguous
and must be made explicit with hql.json.
{
"name": "@scope/name",
"version": "0.1.0",
"exports": {
".": "./mod.hql",
"./math": "./math.hql"
},
"targets": ["jsr", "npm"],
"description": "Optional package description",
"license": "MIT",
"imports": {
"@std/assert": "jsr:@std/assert@^1.0.0"
},
"dependencies": {
"zod": "^3.25.0"
}
}
hql pack writes dist/:
dist/
esm/
index.js npm and browser-oriented ESM
math.js
jsr/
esm/
index.js JSR/Deno-oriented ESM
math.js
types/
index.d.ts
math.d.ts
package.json npm metadata, type: module, exports, types
deno.json Deno metadata and imports
jsr.json JSR metadata and publish include list
README.md copied from root README.md, or generated as a stub
LICENSE copied from root LICENSE, or generated only for default MIT
The npm artifact must not depend on Deno-only specifiers or absolute build
paths. If a target cannot be represented as portable ESM, package validation
fails before upload. Registry metadata includes LICENSE only when the package
actually writes a dist/LICENSE file, so non-MIT packages do not ship stale or
contradictory license text.
| Command | Role |
|---|---|
hql check [path] | Resolve the package and validate source, exports, metadata, and target compatibility |
hql build [path] | Compile package exports into local ESM and declaration outputs |
hql pack [path] | Build plus write registry metadata into dist/; no upload |
hql publish [path] | Pack, then upload to selected registries |
hql init [path] | Materialize the inferred package configuration as hql.json |
hql config [path] | Print the resolved package configuration |
hql add [path] <specifier> | Add an ESM dependency to package config and host metadata |
hql remove [path] <key> | Remove an ESM dependency |
hql install [path] | Install dependencies through the host ecosystem |
hql update [path] | Update dependencies through the host ecosystem |
hql info <specifier> | Show dependency/specifier information |
Package commands accept either a package directory or a single HQL file where
that mode is meaningful. Dependency commands such as add, remove, install,
and update use an optional package path when they edit package state.
hql info inspects a dependency specifier directly. HQL does not require a
global workspace file.
targets is a list of registries to prepare for:
{ "targets": ["jsr", "npm"] }
Target behavior:
| Target | Artifact rule |
|---|---|
jsr | Preserve JSR/Deno-compatible ESM and import maps |
npm | Emit npm package metadata and browser/Node/Bun-oriented ESM |
HQL does not publish CommonJS. The target format is ESM because ESM is the standard JavaScript module system for modern runtimes.
After publishing, consumers use the normal ecosystem syntax:
import { add } from "@scope/name";
import { triple } from "@scope/name/math";
The same package is intended to be consumable by:
Node.js ESM
Bun ESM
Deno or JSR imports
Browser ESM through a standard package CDN or served bundled output
HQL source can import JS, TS, and HQL modules before packing:
JS/TS/HQL source graph
|
v
compiled ESM graph
|
v
published package
|
v
JS/TS/HQL consumers import normal ESM
The published package is not an HQL island. HQL owns the authoring semantics and package validation; the artifact belongs to the JavaScript ESM ecosystem.
(new js/Date)
(js-call console "log" "hello")
(let items [1, 2])
(.push items 3)
items.length
Interop calls are positional. Named labels are only for known HQL fn
declarations.
(macro when [condition ...body]
`(if ~condition
(do ~@body)
nil))
Macro templates operate on S-expression code-data, so quoted lists use spaces:
'(1 2 3)
Macro templates use quasiquote, unquote, and splice forms:
`(+ ~x 1)
Public macro parameters use the same [] parameter-list syntax as functions.
The macro expander may receive bracket forms as normalized (vector ...) lists
internally; that is an implementation detail, not public syntax.
Spaces separate top-level items in structured delimiters. Some items contain spaces or punctuation internally:
| Context | One Item | Example |
|---|---|---|
| Parameter | name: Type = default | [host: string = "localhost" port: number] |
| Rest parameter | ...name: Type | [label: string ...nums: Array<number>] |
| Import alias | name as alias | [identity as id pair as pair-of] |
| Export alias | name as alias | [read as load write as save] |
| Loop binding | from: start | [i from: 0 to: 10 by: 2] |
| Interface member | (fn name [...] -> T) | [(prop id string) (fn get [id:string] -> User)] |
HQL is pre-release, so legacy spellings are removed instead of kept as aliases.
{name: "Ada" age: 37} // use {name: "Ada", age: 37}
#{1 2 3} // use #[1 2 3]
#[1 2, 3] // do not mix persistent and JS set separators
(fn add (a b) ...) // use (fn add [a b] ...)
(fn greet {name: "world"} ...) // use [name: string = "world"]
(connect {host: "api.com"}) // one object argument; use (connect host: "api.com") for named call
(add a: 1, b: 2) // use (add a: 1 b: 2)
(fn sum [first & rest] ...) // use (fn sum [first ...rest] ...)
(constructor (x) ...) // use (constructor [x] ...)