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

Core Reader Rule

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.
  • TypeScript-shaped type expressions keep TypeScript punctuation inside the type grammar.
           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.

Data

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

Bindings

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

Functions

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

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

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

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.

Package Shape

The package root is resolved from the command path:

InputMeaning
hql packUse the current directory
hql pack ./libUse ./lib as the package root
hql pack ./hello.hqlPackage a single HQL file
hql pack ~/Downloads/hello.hqlPackage 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"
  }
}

Generated Artifacts

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.

Commands

CommandRole
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

targets is a list of registries to prepare for:

{ "targets": ["jsr", "npm"] }

Target behavior:

TargetArtifact rule
jsrPreserve JSR/Deno-compatible ESM and import maps
npmEmit 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.

Consumption

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.

JavaScript Interop

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

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