HQL Language

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 expanded syntax reference; SPEC.md is the normative language SSOT. For cross-feature expression and return semantics, see expression-foundation.md. For the opt-in pure/IO discipline layer, see PURE-MODE.md.

Core Reader Rule

HQL uses a Clojure-default reader with an explicit JS interop lane:

  • (...) 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/object) for an empty native JS object.
  • #[] is the empty persistent set. Use (js/set) for an empty native JS Set.
  • Native JS data constructors are explicit: (js/array ...), (js/object ...), (js/set ...), and (js/map ...).
  • TypeScript-shaped type expressions keep TypeScript punctuation inside the type grammar.
           HQL reader
               |
   +-----------+-----------+
   |           |           |
(...)        [...]       {...} / #[...]
code         data        data
spaces       separator chooses persistent vs JS-native data
   |
   +-- quote changes evaluation, not punctuation
       '(a b c) is still parenthesized S-expression syntax
Data shapeHQL persistent laneJS-native laneEmpty form
Vector / Array[1 2], [1], (vector 1 2)[1, 2], [1,], (js/array 1 2)(vector) vs []
Map / Object{}, {:name "Ada"}, {"name" "Ada"}{name: "Ada"}, {"name": "Ada"}, (js/object "name" "Ada"){} vs (js/object)
Set#[], #[1 2], (hash-set 1 2)#[1, 2], (js/set 1 2)#[] vs (js/set)
FormRule
(...)Code form or quoted code-data list. Items are space-separated; commas are rejected.
[1 2, 3], [1, 2 3]Rejected: vector/array separators cannot mix.
{:a 1, :b 2}Rejected: persistent map literals do not use commas.
#[1 2, 3], #[1, 2 3]Rejected: set separators cannot mix.

Use js/ forms when the value must be native JavaScript data.

(+ 1 2 3)
[1 2 3]
[1, 2, 3]       ;; JS array
[]              ;; JS array
[1]             ;; persistent vector
(vector)
(js/array)
(js/array 1 2 3)
{:name "Ada"}
{"name" "Ada" "age" 37}
{"name": "Ada", "age": 37}
(js/object "name" "Ada" "age" 37)
#[1 2 3]
#[1, 2, 3]      ;; JS Set
(js/set 1 2 3)
'(1 2 3)
(type Handler (value:number, opts:{mode:string}) => string)

Invalid source forms:

(+ 1, 2)
[1 2, 3]
{:name "Ada", :age 37}
#{1 2 3}
'(1, 2)

{name: n} remains valid in binding and match patterns, where it destructures object properties. The same JS property-colon spelling is also the public JS object value literal lane when used as an expression.

Data

42
"hello"
true
false
nil

[1 2 3]
[1, 2, 3]
{}
{:name "Ada"}
{"name" "Ada"}
(js/array 1 2 3)
(js/object "name" "Ada" "age" 37)
(js/set 1 2 3)
(js/map "name" "Ada")
#[1 2 3]
#[1, 2, 3]

Objects are data only. They are not named parameters:

(fn inspect [value] value)
(inspect {:name "Ada"})
(inspect (js/object "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

Bindings

(let x 10)
(var count 0)
(let pi 3.14159)

(let [head ...tail] [1 2 3])
(let {name: n, age: a} (js/object "name" "Ada" "age" 37))

Functions

The named declaration shape is:

(fn name [param: Type = default other = value]
  body)

defn is the Clojure-style named function 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

Classes

(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)

Control Flow

(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")
  (case _ "other"))

do groups expressions and evaluates to its last expression:

(do
  (print "step")
  42)

Errors use try/catch with throw:

(try
  (throw (js/Error "boom"))
  (catch e "caught"))

Loops

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))

Types

(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
  (js/Number input))

(protocol Store<T> [
  (prop value T)
  (fn save [item:T label?:string] -> T)
  (call [item:T] -> boolean)
])

Modules

(import [parse stringify] from "@hql/json")

(parse "{\"ok\":true}")
(stringify {ok: true})
(export [parse stringify])

Package Tooling

Package metadata, dependency management, executable builds, and registry publishing are tooling workflows rather than HQL language syntax. Their one canonical user-facing contract is Dependencies And Publishing; command syntax is indexed in HQL CLI. Published libraries are ordinary ESM, so consumers do not need HQL.

JavaScript Interop

(new js/Date)
(js/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.

Macros

(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.

Compound Structured Items

Spaces separate top-level items in structured delimiters. Some items contain spaces or punctuation internally:

ContextOne ItemExample
Parametername: Type = default[host: string = "localhost" port: number]
Rest parameter...name: Type[label: string ...nums: Array<number>]
Import aliasname as alias[identity as id pair as pair-of]
Export aliasname as alias[read as load write as save]
Loop bindingfrom: start[i from: 0 to: 10 by: 2]
Interface member(fn name [...] -> T)[(prop id string) (fn get [id:string] -> User)]

Removed Legacy Syntax

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] ...)