Foreword

GoLisp is a compiled language with Lisp syntax and Go semantics.

You write S-expressions. The compiler produces Go. No interpreter, no VM, no runtime overhead — just your code, translated to idiomatic Go and built with go build.

The bet: Lisp's uniform syntax and functional core pair surprisingly well with Go's type system, concurrency model, and ecosystem. Parentheses feel less like noise when they're also your function call, your data literal, and your macro invocation.

GoLisp is not Clojure with a Go backend. It doesn't pretend to be. Immutable persistent data structures are out; goroutines and channels are in. The error model is Go's — multiple return values, checked explicitly. Structs have methods. Interfaces define contracts. The type system is Go's type system.

What GoLisp adds is expressiveness: closures as first-class values, destructuring in function parameters, a rich collection library, and a syntax that makes higher-order functions feel natural.

It is a general-purpose Lisp hosted on Go, not Go in parentheses. It has its own core vocabulary — the str/, math/, sys/, and cli/ namespaces — and it grows through macros written in the library rather than changes to the compiler. It is as comfortable as a command-line script or a data-processing pipeline as it is running a web service. Go is the host platform, reached deliberately through first-class interop.

This book is a tour. Each chapter teaches one concept through working code. Read it start to finish, or jump to what you need.

All examples can be run with:

glisp run <file>.glsp

Getting Started

Installation

The quickest way is the install script, which fetches a prebuilt binary:

curl -fsSL https://raw.githubusercontent.com/leinonen/golisp-language/main/install.sh | sh

Or build from source — GoLisp requires Go 1.25.5 or later:

go install github.com/leinonen/golisp-language/cmd/glisp@latest

Verify it works:

glisp version

Hello, World

Create a file named hello.glsp:

(ns main)

(defn main [] -> void
  (println "Hello, World!"))

Run it:

glisp run hello.glsp

Build a binary:

glisp build hello.glsp
./hello

The Compiler Commands

CommandWhat it does
glisp run file.glspCompile and run, no output files
glisp run --watch file.glspRe-run on every save
glisp file.glspRun directly (also works via a #!/usr/bin/env glisp shebang)
glisp build file.glspCompile to a binary
glisp build dir/Compile all .glsp files in a directory
glisp fmt file.glspFormat in-place
glisp compile file.glspEmit the generated .go file
glisp print file.glspPrint generated Go to stdout
glisp macroexpand file.glspPrint the file with macros expanded
glisp test file.glspRun test blocks
glisp doc [name]Show documentation for a built-in

Project Layout

Single-file programs need no setup — just a .glsp file with (ns main) and a main function.

For multi-file projects, create a directory:

myapp/
  main.glsp
  handlers.glsp
  models.glsp

Build the whole directory:

glisp build myapp/

For projects with external dependencies, add a glisp.mod file (see Modules).

A First Program

Let's build a command-line statistics tool. It reads numbers from arguments and prints count, sum, min, max, and mean. This example touches functions, collections, error handling, and CLI idioms without explaining each piece in depth — those come in later chapters.

Save this as stats.glsp:

(ns main)

(def default-nums []int [3 1 4 1 5 9 2 6])

(defn parse-arg [s string] -> int
  (if-err [n err] (parse-int s)
    (do (println "not a number:" s) (sys/exit 1) 0)
    n))

(defn main [] -> void
  (let [args (rest (sys/args))]
    (when (contains? args "--help")
      (println "usage: stats [numbers...]")
      (sys/exit 0))
    (let [nums (if (empty? args)
                 default-nums
                 (map (fn [a] (parse-arg (str a))) args))
          sorted (sort nums)
          total  (reduce (fn [acc n] (+ (int acc) (int n))) 0 nums)]
      (when (empty? args) (println "(using defaults)"))
      (println "count:" (len nums))
      (println "sum:  " total)
      (println "min:  " (first sorted))
      (println "max:  " (last sorted))
      (println "mean: " (/ (float64 total) (float64 (len nums)))))))

Run it with the defaults:

glisp run stats.glsp
(using defaults)
count: 8
sum:   31
min:   1
max:   9
mean:  3.875

Run it with your own numbers:

glisp run stats.glsp 10 20 30 40
count: 4
sum:   100
min:   10
max:   40
mean:  25

What's happening here

(sys/args) returns the command-line arguments (program name first). rest drops the program name.

parse-int returns two values: the parsed number and an error. if-err checks the error — if it's non-nil, the first branch runs; otherwise the second branch binds the result to n.

map, filter, reduce are the standard higher-order functions. They work on any collection.

sort returns a new sorted slice. first and last return the first and last elements.

Everything is an expression — if, let, when, function bodies — and the last expression's value is what's returned.

Build a binary and distribute it:

glisp build stats.glsp
./stats 7 3 8 1 9

Expressions and Values

GoLisp has no statements. Everything is an expression that produces a value.

S-Expressions

Code is data. A function call is a list where the first element is the function and the rest are arguments:

(+ 1 2)          ; 3
(* 3 (+ 1 2))    ; 9
(str "hello" " " "world")  ; "hello world"

This uniform syntax means there's no distinction between operators and functions — + is just a function that takes numbers.

Primitive Types

42          ; int
3.14        ; float64
"hello"     ; string
true        ; bool
false       ; bool
nil         ; zero value / absence

Numeric literals are int by default. Use float64 to convert:

(float64 42)   ; 42.0
(int 3.9)      ; 3

Arithmetic on any-typed values (such as map lookups) coerces numbers automatically, so these explicit conversions are needed only when you want to pin a type — see Numeric Auto-Coercion.

Let Bindings

let binds names to values within a scope:

(let [x 10
      y 20]
  (+ x y))    ; 30

Bindings can include an explicit type annotation:

(let [x int 10
      s string "hello"]
  (println s x))

Bindings are sequential — each one can reference the previous:

(let [x 5
      y (* x 2)
      z (+ x y)]
  z)    ; 15

Top-Level Definitions

def binds a value at the top level of a namespace:

(def pi float64 3.14159)
(def greeting "Hello, GoLisp!")
(def primes []int [2 3 5 7 11 13])

Keywords

Keywords are self-evaluating identifiers that start with :. They're used as map keys and field names:

:name
:user-id
:enabled?

Keywords can look up values in maps:

(let [m {:name "Alice" :age 30}]
  (:name m))    ; "Alice"

Truthiness

Only two values are falsy: nil and false. Everything else is truthy — including 0, empty strings, and empty collections.

(if 0    "truthy" "falsy")    ; "truthy"
(if ""   "truthy" "falsy")    ; "truthy"
(if nil  "truthy" "falsy")    ; "falsy"
(if false "truthy" "falsy")   ; "falsy"

This means you can use map lookups directly as conditions without (not= result nil) boilerplate:

(if (get m "key") "found" "missing")

Equality

= compares by value, not identity. Collections are equal when their contents are equal, and numbers compare across types, so an int and an equal float64 are =:

(= [1 2 3] [1 2 3])         ; true
(= {:a 1} {:a 1})           ; true
(= 1 1.0)                   ; true
(= 1 "1")                   ; false  (different types of value)

not= is its negation. This matters when comparing values that flow through any-typed maps or boxed arithmetic — a computed int64 still compares equal to an int literal.

Comments

Single-line comments start with ;:

; This is a comment
(+ 1 2)  ; inline comment

Three semicolons mark a docstring — shown by glisp doc:

;;; Returns the sum of a and b.
(defn add [a int b int] -> int (+ a b))

Functions

Defining Functions

defn declares a named function. Type annotations are positional — the type follows its argument:

(defn add [a int b int] -> int
  (+ a b))

(defn greet [name string] -> string
  (str "Hello, " name "!"))

Call them like any other expression:

(add 3 4)          ; 7
(greet "Alice")    ; "Hello, Alice!"

Multiple Return Values

Functions can return multiple values — this is how Go error handling works:

(defn divide [a float64 b float64] -> [float64 error]
  (if (= b 0.0)
    (values 0.0 (error "division by zero"))
    (values (/ a b) nil)))

Bind multiple return values with let:

(let [[result err] (divide 10.0 3.0)]
  (if err
    (println "error:" err)
    (println "result:" result)))

Anonymous Functions

fn creates an anonymous function:

(fn [x int] -> int (* x x))

; Used inline:
(map (fn [x] (* x 2)) [1 2 3 4])    ; [2 4 6 8]

Closures

Anonymous functions close over their enclosing scope:

(defn make-adder [n int] -> any
  (fn [x int] -> int (+ x n)))

(let [add5 (make-adder 5)]
  (add5 10))    ; 15

Variadic Functions

Use & rest to accept variable arguments:

(defn sum [& nums] -> int
  (reduce (fn [acc n] (+ acc (int n))) 0 nums))

(sum 1 2 3 4 5)    ; 15

Tail Recursion with loop/recur

GoLisp has no general TCO, but loop/recur gives you explicit tail calls:

(defn fib [n int] -> int
  (loop [i int n  a int 0  b int 1]
    (if (<= i 0)
      a
      (recur (- i 1) b (+ a b)))))

(fib 10)    ; 55

loop establishes the bindings; recur jumps back to the top with new values. The type annotations in loop keep arithmetic concrete — no boxing to any.

No-Argument and Void Functions

Functions with no parameters use an empty vector:

(defn greet-world [] -> void
  (println "Hello, World!"))

void means the function returns nothing (Go's no-return-value convention).

Collections

GoLisp has three collection types: vectors, maps, and sets.

Vectors

Vectors are ordered sequences, backed by Go slices:

[1 2 3 4 5]
["alice" "bob" "carol"]
[]                          ; empty vector
[]string                    ; typed empty vector

Common operations:

(count [1 2 3])             ; 3
(first [10 20 30])          ; 10
(last  [10 20 30])          ; 30
(rest  [10 20 30])          ; [20 30]
(nth   [10 20 30] 1)        ; 20
(conj  [1 2 3] 4)           ; [1 2 3 4]
(contains? [1 2 3] 2)       ; true
(empty? [])                 ; true
(reverse [1 2 3])           ; [3 2 1]
(sort [3 1 4 1 5])          ; [1 1 3 4 5]

Maps

Maps are key-value pairs. Keys can be keywords, strings, or any comparable value:

{:name "Alice" :age 30}
{"host" "localhost" "port" 8080}
{}                              ; empty map

Common operations:

(get m :name)                   ; look up a key
(:name m)                       ; keyword shorthand
(assoc m :email "a@example.com"); add/update a key
(dissoc m :age)                 ; remove a key
(merge m1 m2)                   ; merge two maps
(keys m)                        ; all keys
(vals m)                        ; all values
(contains? m :name)             ; true if key exists

Nested access:

(get-in m [:address :city])
(assoc-in m [:address :city] "Helsinki")
(update-in m [:score] inc)

Sets

Sets are unordered collections of unique values:

#{1 2 3}
#{"alice" "bob" "carol"}

Set operations:

(contains? #{:a :b :c} :b)          ; true
(conj #{:a :b} :c)                  ; #{:a :b :c}
(union #{:a :b} #{:b :c})           ; #{:a :b :c}
(intersection #{:a :b} #{:b :c})    ; #{:b}
(difference #{:a :b} #{:b :c})      ; #{:a}

Destructuring

Destructuring unpacks collections into named bindings.

Sequential (vectors):

(let [[a b c] [10 20 30]]
  (println a b c))    ; 10 20 30

; Ignore elements with _
(let [[_ second _] [1 2 3]]
  second)    ; 2

Capture the rest of a sequence with & rest, and the whole value with :as:

(let [[head & tail] [1 2 3 4]]
  (println head tail))         ; 1 [2 3 4]

(let [[a b & rest :as whole] [10 20 30 40]]
  (println a b rest whole))    ; 10 20 [30 40] [10 20 30 40]

Patterns nest — destructure a vector of vectors in one step:

(let [[[x y] [u v]] [[1 2] [3 4]]]
  (+ x y u v))                 ; 10

Map destructuring:

(let [{name :name age :age} {:name "Alice" :age 30}]
  (println name age))    ; Alice 30

:keys is shorthand when the binding name matches the key. :or supplies defaults for missing keys, and :as binds the whole map:

(let [{:keys [name age] :or {age 0} :as person}
      {:name "Alice"}]
  (println name age person))
; Alice 0 {:name "Alice"}

In function parameters:

(defn greet [{name :name}] -> string
  (str "Hello, " name "!"))

(greet {:name "Alice"})    ; "Hello, Alice!"

Annotated map destructuring types each field as it is pulled out — handy for unpacking an untyped request map into typed locals without per-field casts:

(defn signup [{name :name :- string age :age :- int}] -> string
  (format "%s is %d" name age))

Core Collection Functions

(map (fn [x] (* x 2)) [1 2 3])         ; [2 4 6]
(map-indexed (fn [i x] [i x]) [:a :b]) ; [[0 :a] [1 :b]]
(filter even? [1 2 3 4 5 6])            ; [2 4 6]
(reduce + 0 [1 2 3 4 5])                ; 15
(take 3 [1 2 3 4 5])                    ; [1 2 3]
(drop 3 [1 2 3 4 5])                    ; [4 5]
(flatten [[1 2] [3 4] [5]])             ; [1 2 3 4 5]
(distinct [1 2 2 3 3 3])                ; [1 2 3]
(group-by even? [1 2 3 4 5])            ; {true [2 4] false [1 3 5]}
(zipmap [:a :b :c] [1 2 3])             ; {:a 1 :b 2 :c 3}
(into #{} [1 2 2 3])                    ; #{1 2 3}

map-indexed passes the index alongside each element — the callback takes (index item).

List Comprehension

for builds a vector by iterating over one or more bindings. An optional :when guard filters elements:

(for [x [1 2 3 4 5] :when (> x 2)]
  (* x x))                       ; [9 16 25]

Multiple bindings nest as a cartesian product:

(for [x [1 2] y [:a :b]]
  [x y])                         ; [[1 :a] [1 :b] [2 :a] [2 :b]]

Control Flow

if and when

if takes a condition, a then-branch, and an optional else-branch:

(if (> x 0) "positive" "non-positive")

; Without else — returns nil when condition is false
(if (> x 0) (println "positive"))

when is if without an else, but accepts multiple body expressions:

(when (> x 0)
  (println "x is positive")
  (println "x =" x))

cond

cond tests multiple conditions in order, returning the first truthy match:

(defn grade [score int] -> string
  (cond
    (>= score 90) "A"
    (>= score 80) "B"
    (>= score 70) "C"
    (>= score 60) "D"
    :else         "F"))

:else is a common idiom for the default case — it's just a keyword, which is always truthy.

switch and case

switch dispatches on equality, like Go's switch:

(defn http-status [code int] -> string
  (switch code
    200 "OK"
    201 "Created"
    404 "Not Found"
    500 "Internal Server Error"
    :default "Unknown"))

case is the Clojure-style variant where the default is a trailing unpaired expression:

(defn day-type [day string] -> string
  (case day
    "Saturday" "weekend"
    "Sunday"   "weekend"
    "weekday"))    ; trailing default

Both compile to Go's switch statement.

Conditional Binding

if-let evaluates an expression, binds the result, and branches based on truthiness:

(defn find-user [id string] -> string
  (if-let [user (lookup-user id)]
    (str "Found: " (:name user))
    "Not found"))

The else branch runs when the binding is nil or false.

when-let is the same but with no else — just runs the body when the binding is truthy:

(when-let [session (get-session req)]
  (log! "session user:" (:user-id session)))

do

do sequences multiple expressions, returning the last value:

(if error
  (do
    (log/error "something went wrong" "err" error)
    (sys/exit 1))
  (println "ok"))

Loops

doseq iterates a collection for side effects:

(doseq [x [1 2 3 4 5]]
  (println x))

dotimes runs a body n times with an index:

(dotimes [i 5]
  (println "iteration" i))

For loops that produce values or need recursion, see Functionsloop/recur.

Error Handling

GoLisp follows Go's error model: functions return errors as values. There's no try/catch.

Multiple Return Values

A function that can fail returns [result error]:

(defn divide [a float64 b float64] -> [float64 error]
  (if (= b 0.0)
    (values 0.0 (error "division by zero"))
    (values (/ a b) nil)))

if-err

if-err is the idiomatic way to check errors. It binds both return values and branches on whether the error is non-nil:

(if-err [result err] (divide 10.0 3.0)
  (println "failed:" err)       ; error branch
  (println "result:" result))   ; success branch

The error branch runs first — this mirrors the Go convention of handling errors before using values.

Nested operations read naturally:

(defn read-json [path string] -> [any error]
  (if-err [content err] (read-file path)
    (values nil err)
    (if-err [data err] (json/decode content)
      (values nil err)
      (values data nil))))

Creating Errors

error creates an error from a string:

(error "something went wrong")

wrap-error wraps an existing error with additional context:

(wrap-error "failed to load config" err)

Check if an error matches a specific type:

(errors/is? err io/EOF)

let-or — Sequential Nil Guard

let-or chains operations where each step can fail. If any binding is nil, it short-circuits to the fallback:

(defn process-request [req any] -> any
  (let-or
    [body  (web/body-map req)        {"error" "missing body"}
     title (get body "title")        {"error" "missing title"}
     user  (get body "user")         {"error" "missing user"}]
    {:title title :user user}))

Each binding has three parts: name, expression, fallback. The fallback is returned as soon as a binding resolves to nil.

Panic and Recover

For truly unrecoverable situations, panic stops the program:

(assert (> n 0) "n must be positive")
(panic "unreachable state")

recover can catch a panic inside a deferred function:

(defn safe-call [f any] -> any
  (defer (fn [] -> void
    (let [r (recover)]
      (when r (println "caught panic:" r)))))
  (f))

Panics are rare. Most failures should return an error value.

Practical Pattern

A typical function that reads, parses, and uses data:

(defn load-config [path string] -> [any error]
  (if-err [raw err] (read-file path)
    (values nil (wrap-error "read failed" err))
    (if-err [cfg err] (json/decode raw)
      (values nil (wrap-error "parse failed" err))
      (values cfg nil))))

(defn main [] -> void
  (if-err [cfg err] (load-config "config.json")
    (do (println "error:" err) (sys/exit 1))
    (println "loaded:" cfg)))

Macros

GoLisp code is data. A macro is a function that runs at compile time, taking unevaluated forms and returning a new form to compile in their place. This is how the language grows without changing the compiler — much of core (including ->, ->>, when-not, and if-not) is written as macros, not built in.

Defining a Macro

defmacro looks like defn, but its arguments arrive as code and its result is code. Use syntax-quote ` to write a template, ~ to splice in a value, and ~@ to splice in the elements of a sequence:

;;; (unless test body...) — evaluate body only when test is falsy.
(defmacro unless [test & body]
  `(if ~test nil (do ~@body)))

Now unless reads like a built-in form:

(unless (> x 100)
  (println "x is small"))

At compile time that expands to:

(if (> x 100) nil (do (println "x is small")))

The Reader Syntax

SyntaxNameMeaning
`formsyntax-quoteBuild form as data (a template)
~xunquoteSplice the value of x into the template
~@xsunquote-spliceSplice the elements of sequence xs
'formquoteform as data, evaluating nothing

Auto-gensym

A macro that introduces its own binding needs a name that can't collide with the caller's code. Write name# inside a syntax-quote and each occurrence expands to one fresh symbol:

;;; (inc-all coll) — add 1 to every element.
(defmacro inc-all [coll]
  `(map (fn [n#] (+ n# 1)) ~coll))

(inc-all [1 2 3])    ; [2 3 4]

The n# becomes a unique symbol like n__1__auto, so the macro never captures a variable from the call site. GoLisp's hygiene model is Clojure's: non-hygienic by default, with auto-gensym (and the gensym built-in) as the tool you reach for.

Inspecting Expansion

glisp macroexpand file.glsp prints the file with every macro call expanded — the fastest way to see what your macro produces:

glisp macroexpand mymacros.glsp
(defn main [] -> void
  (if (> x 100) nil (do (println "x is small")))
  (println (map (fn [n__1__auto] (+ n__1__auto 1)) [1 2 3])))

What Runs in a Macro Body

Macro bodies run in the compiler, not the final program, so they see only a compile-time subset of the language: fn, let, if, cond, do, the logic operators, and pure list/map/symbol helpers (first, rest, map, reduce, conj, concat, symbol, keyword, gensym, str, …). They cannot do I/O, spawn goroutines, or call Go interop — a macro computes a form, nothing more.

This is enough to do real work. The thread-first macro is just a reduce over its forms:

;;; (-> x form...) — insert x as the first argument of each form.
(defmacro -> [x & forms]
  (reduce
    (fn [acc form]
      (if (list? form)
        `(~(first form) ~acc ~@(rest form))
        `(~form ~acc)))
    x
    forms))

Two rules to remember: macros are define-before-use (a macro must appear above its first call, in a single top-to-bottom pass), and forms that must control Go's type system or statement placement — let with typed bindings, defn itself — stay built in rather than being macro-defined.

Structs and Types

Defining Structs

defstruct declares a named struct with typed fields:

(defstruct Point
  x float64
  y float64)

(defstruct User
  id     string
  name   string
  email  string
  active bool)

Creating Struct Values

Use a map literal in a context where the type is known:

(defn make-point [x float64 y float64] -> Point
  {:x x :y y})

Or use the explicit struct literal syntax:

(Point. {:x 1.0 :y 2.0})
(User. {:id "1" :name "Alice" :email "alice@example.com" :active true})

Accessing Fields

Keyword access on a struct becomes a direct field read:

(let [p (Point. {:x 3.0 :y 4.0})]
  (:x p)    ; 3.0
  (:y p))   ; 4.0

Updating Structs

Structs are values. "Update" means creating a new struct with changed fields:

(defn move [p Point dx float64 dy float64] -> Point
  {:x (+ (:x p) dx)
   :y (+ (:y p) dy)})

Named Types

deftype creates a new named type based on a primitive:

(deftype UserId string)
(deftype Score int)
(deftype Percentage float64)

Named types prevent accidentally mixing values that have different meanings:

(def user-id UserId "user-123")
(def session-id string "sess-456")

; These are different types even though both are strings

Stateful Fields with Atoms

Struct fields are normally immutable values. When a struct needs its own mutable, thread-safe state, give it an atom field typed (Atom T) (or bare Atom):

(defstruct Counter
  label string
  count (Atom int))

(defn new-counter [name string] -> Counter
  {:label name :count (atom int 0)})

(defn bump! [c Counter] -> any
  (swap! (:count c) (fn [n] (+ (int n) 1))))

The struct carries its own state, so there's no need for a module-level singleton. See Atoms for atom, swap!, reset!, and deref.

Gradual Typing

Start with a plain map and upgrade to a struct as the code grows. When a function's parameter is typed as a struct, map literals in call position are automatically compiled as struct literals:

(defstruct Product
  name     string
  price    float64
  stock    int)

; This map literal compiles to a Product struct literal
; because the function declares Product as the parameter type
(defn price-with-tax [p Product] -> float64
  (* (:price p) 1.24))

(price-with-tax {:name "Widget" :price 9.99 :stock 42})

The benefit: misspell :pricee and Go's compiler catches it at build time.

Example: A Complete Struct

(ns main)

(defstruct Circle
  radius float64)

(defstruct Rect
  width  float64
  height float64)

(defn circle-area [c Circle] -> float64
  (* math/pi (:radius c) (:radius c)))

(defn rect-area [r Rect] -> float64
  (* (:width r) (:height r)))

(defn main [] -> void
  (let [c (Circle. {:radius 5.0})
        r (Rect. {:width 4.0 :height 6.0})]
    (println "circle area:" (circle-area c))
    (println "rect area:  " (rect-area r))))

Interfaces and Methods

Defining an Interface

definterface declares a Go interface — a set of methods a type must implement:

(definterface Shape
  (Area    [] -> float64)
  (Describe [] -> string))

Implementing Methods

defmethod attaches a method to a struct. The first parameter is the receiver:

(defstruct Circle
  radius float64)

(defmethod Circle Area [c] -> float64
  (* math/pi (:radius c) (:radius c)))

(defmethod Circle Describe [c] -> string
  (fmt/sprintf "circle(r=%.2f)" (:radius c)))

(defstruct Rect
  width  float64
  height float64)

(defmethod Rect Area [r] -> float64
  (* (:width r) (:height r)))

(defmethod Rect Describe [r] -> string
  (fmt/sprintf "rect(%.2f × %.2f)" (:width r) (:height r)))

Calling Methods

Methods are called as regular functions — the receiver is just the first argument. The compiler routes the call to the right method:

(area c)       ; → c.Area()
(describe c)   ; → c.Describe()

This is dot-free dispatch. No (.Area c) syntax needed for your own methods.

Using the Interface

Functions that accept an interface work with any implementing type:

(defn print-shape [s Shape] -> void
  (fmt/printf "%s: area = %.4f\n" (describe s) (area s)))

(defn main [] -> void
  (let [shapes [
    (Circle. {:radius 5.0})
    (Rect.   {:width 4.0 :height 6.0})]]
    (doseq [s shapes]
      (print-shape s))))

Output:

circle(r=5.00): area = 78.5398
rect(4.00 × 6.00): area = 24.0000

Methods on Named Types

defmethod works on deftype too:

(deftype Celsius float64)
(deftype Fahrenheit float64)

(defmethod Celsius ToF [c] -> Fahrenheit
  (Fahrenheit (+ (* (float64 c) 9.0 / 5.0) 32.0)))

(let [c (Celsius 100.0)]
  (to-f c))    ; Fahrenheit(212.0)

Full Example

(ns main)

(definterface Shape
  (Area    [] -> float64)
  (Describe [] -> string))

(defstruct Circle  radius float64)
(defstruct Rect    width float64  height float64)
(defstruct Triangle a float64  b float64  c float64)

(defmethod Circle Area [c] -> float64
  (* math/pi (:radius c) (:radius c)))
(defmethod Circle Describe [c] -> string
  (fmt/sprintf "circle(r=%.2f)" (:radius c)))

(defmethod Rect Area [r] -> float64
  (* (:width r) (:height r)))
(defmethod Rect Describe [r] -> string
  (fmt/sprintf "rect(%.2f x %.2f)" (:width r) (:height r)))

(defmethod Triangle Area [t] -> float64
  (let [s (* 0.5 (+ (:a t) (:b t) (:c t)))]
    (math/sqrt (* s (- s (:a t)) (- s (:b t)) (- s (:c t))))))
(defmethod Triangle Describe [t] -> string
  (fmt/sprintf "triangle(%.2f, %.2f, %.2f)" (:a t) (:b t) (:c t)))

(defn total-area [shapes []Shape] -> float64
  (reduce (fn [sum s] (+ sum (area s))) 0.0 shapes))

(defn main [] -> void
  (let [shapes []Shape [
    (Circle.   {:radius 5.0})
    (Rect.     {:width 4.0 :height 6.0})
    (Triangle. {:a 3.0 :b 4.0 :c 5.0})]]
    (doseq [s shapes]
      (fmt/printf "  %-26s area = %.4f\n" (describe s) (area s)))
    (println "total area:" (total-area shapes))))

Go Interop

GoLisp compiles to Go. Every Go package is available — the interop syntax is lightweight and predictable.

Calling Go Methods

Prefix a method name with . to call it on a value:

(.Year t)              ; → t.Year()
(.Format t "2006-01-02")  ; → t.Format("2006-01-02")
(.Close conn)          ; → conn.Close()
(.String err)          ; → err.Error() ... actually err.String()

This is the syntax for Go methods you don't own. For your own defmethod methods, just call them by name.

Accessing Fields

Prefix with .- to read a struct field:

(.-Timeout client)     ; → client.Timeout
(.-Status resp)        ; → resp.Status

Struct Literals

Use TypeName. followed by a map to create a Go struct:

(http/Client. {})
(http/Client. {:Timeout (* 30 time/Second)})
(sync/Mutex. {})
(net/TCPAddr. {:IP net/IPv4loopback :Port 8080})

Qualified Calls

Call functions from any Go package using package/FunctionName:

(time/now)                         ; → time.Now()
(strings/has-prefix s "https://")  ; → strings.HasPrefix(s, "https://")
(strings/to-upper s)               ; → strings.ToUpper(s)
(math/sqrt 2.0)                    ; → math.Sqrt(2.0)
(fmt/sprintf "%.2f" 3.14)          ; → fmt.Sprintf("%.2f", 3.14)
(os/exit 1)                        ; → os.Exit(1)

Package Constants and Variables

Access package-level constants and variables with /:

math/pi             ; → math.Pi
time/Second         ; → time.Second
time/Millisecond    ; → time.Millisecond
os/stdin            ; → os.Stdin
io/EOF              ; → io.EOF

Type Assertions

Use as to assert a value's type:

(as *Circle val)        ; → val.(*Circle)
(as string raw)         ; → raw.(string)
(as error iface)        ; → iface.(error)

Importing External Go Packages

Declare imports in ns:

(ns main
  (:import [github.com/google/uuid]
           [github.com/jackc/pgx/v5]))

(defn new-id [] -> string
  (uuid/new-string))

(defn connect [url string] -> [any error]
  (pgx/connect (ctx/background) url))

Add the dependency to glisp.mod:

module github.com/myuser/myapp

go-require (
  github.com/google/uuid v1.6.0
  github.com/jackc/pgx/v5 v5.7.2
)

Then fetch it:

glisp get -go github.com/google/uuid@v1.6.0

Common Patterns

Time:

(let [now (time/now)
      year (.Year now)
      formatted (.Format now "2006-01-02")]
  (println year formatted))

HTTP client:

(let [client (http/Client. {:Timeout (* 10 time/Second)})]
  (if-err [resp err] (.Get client "https://example.com")
    (println "error:" err)
    (println "status:" (.-StatusCode resp))))

Sorting with a custom comparator:

(sort/slice items (fn [i int j int] -> bool
  (< (:priority (nth items i)) (:priority (nth items j)))))

doto — Fluent Configuration

doto evaluates a value once, runs a sequence of side-effecting steps on it, and returns the value. A (.method args) step threads the value as the receiver, so builder-style APIs read as a chain:

(doto (new-box)
  (.Add "a")
  (.Add "b")
  (.Show))    ; returns the box

A bare (fn args) step threads the value as the first argument instead. Either way, doto returns the original object — handy for "build, configure, hand back" patterns without a throwaway let.

Numeric Auto-Coercion

Values typed as any — map lookups, untyped parameters, range variables — coerce automatically in arithmetic and comparisons. No explicit cast is needed:

(defn total [m] -> int
  (+ (get m "a") (get m "b")))    ; map values are `any`; result coerces to int

Integer-ness is preserved when no float is involved. Typed numeric code stays native (no coercion overhead), and explicit int / float64 remain available when you want to pin a conversion.

Auto-Imported Packages

These packages are imported automatically when used — no ns declaration needed:

fmt, os, strings, strconv, sort, math, time, sync

Functional Programming

Functions are first-class values. Pass them, return them, compose them.

map, filter, reduce

The core trio:

(map (fn [x] (* x x)) [1 2 3 4 5])    ; [1 4 9 16 25]

(filter even? [1 2 3 4 5 6])           ; [2 4 6]

(reduce + 0 [1 2 3 4 5])               ; 15
(reduce str "" ["a" "b" "c"])          ; "abc"

map and filter accept any callable — named function, anonymous function, or keyword.

Keywords as Functions

A bare keyword acts as a field accessor — useful with map:

(def movies
  [{:title "Arrival"   :year 2016 :rating 7.9}
   {:title "Heat"      :year 1995 :rating 8.3}
   {:title "Parasite"  :year 2019 :rating 8.5}])

(map :title movies)     ; ["Arrival" "Heat" "Parasite"]
(map :rating movies)    ; [7.9 8.3 8.5]

partial

partial fixes some arguments, returning a new function for the rest:

(def add5 (partial + 5))
(add5 10)    ; 15
(add5 20)    ; 25

(def short? (partial shorter-than? 120))
(filter short? movies)

comp

comp chains functions right-to-left:

(def loud-greet (comp str/upper (partial str "Hello, ")))
(loud-greet "alice")    ; "HELLO, ALICE"

complement

complement negates a predicate:

(def odd? (complement even?))
(filter odd? [1 2 3 4 5])    ; [1 3 5]

juxt

juxt applies multiple functions to the same value, returning a vector of results:

((juxt :title :year :rating) (first movies))
; ["Arrival" 2016 7.9]

(map (juxt :title :rating) movies)
; [["Arrival" 7.9] ["Heat" 8.3] ["Parasite" 8.5]]

apply

apply calls a function with a collection as its argument list:

(apply + [1 2 3 4 5])       ; 15
(apply str ["a" "b" "c"])   ; "abc"
(apply max [3 1 4 1 5 9])   ; 9

Advanced Collection Functions

; Sort by a derived key
(sort-by :rating movies)
(sort-by (comp - :rating) movies)    ; descending

; Group into a map
(group-by :year movies)
; {2016 [{...}] 1995 [{...}] 2019 [{...}]}

; Count occurrences
(frequencies [:a :b :a :c :b :a])
; {:a 3 :b 2 :c 1}

; Partition into chunks
(partition 2 [1 2 3 4 5 6])
; [[1 2] [3 4] [5 6]]

; Take/drop while a condition holds
(take-while even? [2 4 6 1 2])    ; [2 4 6]
(drop-while even? [2 4 6 1 2])    ; [1 2]

; like map but filters nils
(keep (fn [x] (when (even? x) (* x 10))) [1 2 3 4])
; [20 40]

Pipelines Without Threading Macros

Chain operations with intermediate let bindings:

(defn top-picks [catalog any watched any] -> []any
  (let [unseen (filter (fn [m] (not (contains? watched (:title m)))) catalog)
        ranked (sort-by (fn [m] (- (float64 (:rating m)))) unseen)
        top    (take 3 ranked)]
    (map :title top)))

Or with threading macros (if you prefer):

(defn top-picks [catalog any watched any] -> []any
  (->> catalog
       (filter (fn [m] (not (contains? watched (:title m)))))
       (sort-by (fn [m] (- (float64 (:rating m)))))
       (take 3)
       (map :title)))

->> threads each result as the last argument. -> threads as the first argument.

When the threaded value needs to land in a different position from one step to the next, as-> binds it to a name you choose:

(as-> {} $
  (assoc $ "k" 1)
  (dissoc $ "old")
  (merge $ {"done" true}))

Each form may reference $ anywhere — first arg, last arg, or buried in the middle.

Nil-Safe and Conditional Threading

some-> and some->> thread like ->/->> but short-circuit to nil the moment any step yields nil — they collapse a stack of nil-guards into one line:

; Returns nil if any key is missing, instead of panicking on a nil map
(some-> req
        (get "user")
        (get "email")
        str/lower)

(some->> orders
         (filter paid?)
         (map :total)
         (reduce +))

cond-> and cond->> thread the value only through the forms whose paired test is truthy. Unlike cond, every test is checked, so it is ideal for building a value up from optional pieces:

(defn build-query [base any opts any] -> any
  (cond-> base
    (:active opts)   (assoc :status "active")
    (:since opts)    (assoc :since (:since opts))
    (:limit opts)    (assoc :limit (:limit opts))))

cond->> is the same but threads as the last argument of each chosen form.

Calling Function Values

A function stored in an any-typed binding — the result of comp, juxt, partial, or a map lookup — is callable directly, no apply needed:

(defn run-twice [f any x any] -> any
  (f (f x)))

(run-twice (partial + 3) 10)    ; 16

(let [handlers {:greet (fn [n] (str "hi " n))}]
  ((:greet handlers) "Ada"))    ; "hi Ada"

Bare Functions as Arguments

A single-argument named or core function can be passed straight into a higher-order function — the compiler wraps it to fit:

(map str/upper ["a" "b"])       ; ["A" "B"]
(filter str/blank? lines)
(map :title movies)

Multi-argument functions like + still need an explicit wrapper in these positions: (reduce (fn [a b] (+ a b)) 0 nums). If a function's shape doesn't fit the slot, the compiler reports it at the call site and suggests the wrapper.

Debugging Pipelines

pp pretty-prints a value (sorted map keys, indented nesting) and returns it unchanged, so it drops into any expression without disturbing the result:

(pp {:b 2 :a 1})    ; prints the map, returns it

tap-> and tap->> are ->/->> that pretty-print each intermediate stage — a pipeline you can watch:

(tap-> 5 (+ 3) (* 2))    ; prints 5, then 8, then 16; returns 16

time-it evaluates an expression, prints how long it took, and returns its value:

(time-it (expensive-computation))

All three pass their value through untouched, so you can wrap a subexpression to inspect it and remove the wrapper later without changing behavior.

Concurrency

GoLisp exposes Go's concurrency primitives directly: goroutines, channels, select, and synchronization.

Goroutines

go spawns a goroutine:

(go (println "running concurrently"))

go-val spawns a goroutine and returns a typed channel — the future pattern:

(defn fetch [url string] -> (chan string)
  (go-val string
    (if-err [resp err] (http/get url)
      (str "error: " err)
      (:body resp))))

; Submit work
(let [ch (fetch "https://example.com")]
  ; ... do other things ...
  (let [result (recv! ch)]
    (println result)))

Channels

Create a channel with chan:

(chan string)      ; unbuffered string channel
(chan int 10)      ; buffered int channel with capacity 10
(chan any)         ; untyped channel

Send and receive:

(send! ch "hello")           ; send (blocks if unbuffered and no receiver)
(recv! ch)                   ; receive (blocks until value available)
(let [[val ok] (recv-ok! ch)] ; receive with close detection
  (if ok (use val) (println "channel closed")))
(close! ch)                  ; close the channel

Range over a channel until it closes:

(for-chan [msg ch]
  (println "received:" msg))

select!

select! waits on multiple channel operations:

(select!
  ([val ch1] (println "from ch1:" val))
  ([val ch2] (println "from ch2:" val))
  (:timeout 5000 (println "timed out after 5s"))
  (:default  (println "nothing ready")))

:timeout fires after N milliseconds. :default fires immediately if nothing else is ready.

par — Parallel Execution

par runs expressions concurrently and waits for all to finish:

(par
  (seed-database!)
  (warm-cache!)
  (start-metrics!))

Each expression runs in its own goroutine. par blocks until all complete.

Atoms

An atom is a thread-safe holder for a single value. swap! updates it by applying a function, holding an internal lock so concurrent updates never conflict:

(def counter (atom 0))

(swap! counter (fn [n] (+ n 1)))   ; apply f atomically, returns the new value
(reset! counter 0)                 ; set unconditionally
(deref counter)                    ; read the current value

A typed atom records its element type. deref then returns a concrete value — no cast needed — and (Atom int) documents the type in signatures:

(defn make-counter [] -> (Atom int)
  (atom int 0))

(defn increment! [c (Atom int)] -> any
  (swap! c (fn [n] (+ (int n) 1))))

(defn current [c (Atom int)] -> int
  (deref c))    ; already an int

Atoms are the simplest way to share mutable state across goroutines — each spawns work, all swap! the same atom, and the final deref sees every update:

(let [counter (atom int 0)
      done (chan bool 5)]
  (doseq [_ (range 5)]
    (go
      (doseq [_ (range 1000)] (increment! counter))
      (send! done true)))
  (doseq [_ (range 5)] (recv! done))
  (println "total:" (current counter)))    ; 5000, no data races

with-open

with-open binds resources, runs the body, and closes each one on the way out — even if the body panics. Anything with a Close method is closed:

(with-open [f (open-file "notes.txt")]
  (read-contents f))    ; f is closed before with-open returns

Multiple bindings open left-to-right and close in reverse (LIFO), so a resource is always released before the ones it depends on:

(with-open [in  (open-file src)
            out (open-file dst)]
  (copy-file in out))   ; out closes first, then in

defer

defer schedules cleanup to run when the enclosing function returns:

(defn process-file [path string] -> error
  (if-err [f err] (os/open path)
    err
    (do
      (defer (.Close f))
      ; ... use f ...
      nil)))

Practical Example: Concurrent Job Runner

(ns main)

(defn run-job [name string delay time/Duration] -> string
  (time/sleep delay)
  (str "done:" name))

(defn submit [name string delay time/Duration] -> (chan string)
  (go-val string (run-job name delay)))

(defn main [] -> void
  (let [jobs [["fetch"  (* 50 time/Millisecond)]
              ["parse"  (* 30 time/Millisecond)]
              ["store"  (* 80 time/Millisecond)]]
        futures (map (fn [[name delay]] (submit name delay)) jobs)
        out (chan string (len futures))]
    ; Collect results with a 200ms timeout per job
    (doseq [ch futures]
      (select!
        ([result ch]
          (send! out result))
        (:timeout 200
          (send! out "timed out"))))
    (close! out)
    (for-chan [r out]
      (println r))))

WaitGroup Pattern

For fire-and-forget goroutines that must all finish before continuing:

(defn main [] -> void
  (let [wg (sync/WaitGroup. {})]
    (doseq [i (range 5)]
      (.Add wg 1)
      (go
        (defer (.Done wg))
        (println "worker" i)))
    (.Wait wg)
    (println "all done")))

Pipelines: fan-in, pipeline, fan-out

Three forms compose channels into concurrent dataflows. fan-in merges several channels into one. pipeline chains transformation stages, each running in its own goroutine connected by internal channels — [n merged] binds each value from the source, and every stage rebinds the symbol to its own result. fan-out runs n worker goroutines that drain a channel in parallel:

(defn emit [vals []any] -> (chan any)
  (let [ch (chan any 8)]
    (go (doseq [v vals] (send! ch v)) (close! ch))
    ch))

(defn main [] -> void
  (let [merged  (fan-in (emit [1 2 3]) (emit [10 20 30]))   ; one stream
        doubled (pipeline [n merged] (* (int n) 2))         ; staged transform
        results (chan any 16)]
    (go
      (fan-out 3 [n doubled]            ; 3 workers drain `doubled`
        (send! results (str "got " n)))
      (close! results))                 ; runs once all workers finish
    (for-chan [r results] (println (str r)))))

fan-out blocks until every worker is done, so wrapping it in (go …) lets the main goroutine drain results while the workers run. Channel values are any, so coerce them ((int n)) before arithmetic.

Scripting and the CLI

GoLisp compiles to a single static binary, which makes it a good fit for command-line tools and scripts. You can run a .glsp file directly, parse options declaratively, shell out to other programs, and walk the filesystem — all from core, with no imports.

Running Scripts

For quick iteration, run a file without producing any artifacts:

glisp run stats.glsp 3 1 4 1 5
glisp run --watch server.glsp     # re-run on every save

Add a shebang and a file is directly executable:

#!/usr/bin/env glisp
(ns main)

(defn main [] -> void
  (println "ahoy"))
chmod +x ahoy.glsp
./ahoy.glsp

When you want a distributable binary instead, glisp build stats.glsp produces one.

Arguments and Environment

The sys/ namespace fronts the process and its environment:

(rest (sys/args))           ; arguments after the program name
(sys/env "PORT")            ; "" if unset
(sys/env "PORT" "8080")     ; with a default
(sys/exit 1)                ; exit with a status code

Parsing Options

cli/parse-opts turns raw arguments into options and positionals. Each spec is a map: :long (required), :short, :desc, :default, :flag (a boolean that consumes no value), and :int (parse the value as an integer):

(def specs []any
  [{:long "--name" :short "-n" :desc "Who to greet" :default "world"}
   {:long "--loud" :short "-l" :desc "Shout"        :flag true}])

(defn main [] -> void
  (let [parsed (cli/parse-opts (rest (sys/args)) specs)
        opts (:options parsed)
        msg  (str "hello, " (:name opts))]
    (when (not (empty? (:errors parsed)))
      (doseq [e (:errors parsed)] (println "error:" e))
      (println (:summary parsed))
      (sys/exit 1))
    (println (if (:loud opts) (str/upper msg) msg))
    (println "extra args:" (:arguments parsed))))

The result is a map: :options (keyed by each long name without --, so :name and :loud), :arguments (the positionals), :errors (unknown options, missing values, bad integers), and :summary (generated help text). --name value, --name=value, and -n value are all accepted; -- ends option parsing.

Running Other Programs

proc/run executes a command directly; proc/sh runs a string through sh -c so you get pipes, globs, and redirection. Both return {:out :err :exit :ok}:

(let [r (proc/run "git" "rev-parse" "HEAD")]
  (if (:ok r)
    (println "commit:" (str/trim (:out r)))
    (println "git failed (" (:exit r) "):" (:err r))))

(str/trim (:out (proc/sh "ls -1 | wc -l")))   ; count files via the shell

Files and Paths

slurp reads a whole file, spit writes one, and lines splits text on newlines. Path helpers front path/filepath, and glob/walk find files:

(spit "/tmp/note.txt" "hello\nworld")

(if-err [text err] (slurp "/tmp/note.txt")
  (println "read failed:" err)
  (doseq [l (lines text)] (println "line:" l)))

(path/join "src" "main.glsp")     ; "src/main.glsp"
(path/base "src/main.glsp")       ; "main.glsp"
(path/ext  "main.glsp")           ; ".glsp"

(glob "*.txt")                    ; matching files in the current dir
(doseq [f (walk "examples")]      ; every file under a tree, recursively
  (when (str/ends-with? f ".glsp")
    (println (path/base f))))

That is the whole scripting kit: read input, transform it, shell out where it's easier, and write the result — compiled to one binary you can drop anywhere.

Data Processing

GoLisp's collection functions handle most transformations directly. When the pipeline matters — because you want to compose steps, avoid intermediate collections, or stream a file too large for memory — reach for transducers and the streaming readers. All of it is in core, no imports.

Transducers

Called with a single argument, map, filter, remove, keep, take, drop, take-while, and drop-while return a transducer — a transformation with no collection attached. comp composes them (data flows left to right), and you run the result with sequence, transduce, or into:

(def xf (comp (map (fn [x] (* x x)))
              (filter (fn [x] (> x 5)))
              (take 2)))

(sequence xf (range 1 1000000))           ; [9 16] — realize to a vector
(transduce xf (fn [a x] (+ a x)) 0 nums)  ; fold to a single value
(into [] (map (fn [x] (+ x 1))) [1 2 3])  ; [2 3 4] — pour into a collection

take and take-while short-circuit: the (range 1 1000000) above stops after the first two passing elements, so the source is never fully walked. The same xf works on any source — that reuse is the point of separating the transformation from the data.

CSV

csv/parse reads CSV text into a list of maps, keyed by the header row; csv/write does the reverse. Both return [value error], so pair them with if-err:

(spit "people.csv" "name,age\nalice,34\nbob,17\n")

(if-err [content err] (slurp "people.csv")
  (println "read failed:" err)
  (if-err [rows perr] (csv/parse content)
    (println "parse failed:" perr)
    (let [adults (filter (fn [r] (>= (int (:age r)) 18)) rows)]
      (if-err [out werr] (csv/write adults)
        (println "write failed:" werr)
        (spit "adults.csv" out)))))

Each row is a map[string]any, so keyword access ((:age r)) and all the map functions work on it. csv/write takes the header from the first row's keys (sorted).

Streaming Large Inputs

read-lines returns a whole file's lines. When the file is too big to hold in memory, transduce-lines streams it through a transducer pipeline in constant memory — and because take/take-while stop early, it reads only as far as it needs:

; pull the first 100 ERROR lines out of an arbitrarily large log
(if-err [errs e] (transduce-lines
                   (comp (filter (fn [l] (str/includes? l "ERROR")))
                         (take 100))
                   (fn [acc l] (conj acc l)) [] "app.log")
  (println "read failed:" e)
  (spit "errors.log" (str/join "\n" errs)))

transduce-json does the same for the elements of a top-level JSON array, streaming one element at a time instead of decoding the whole document:

(if-err [top e] (transduce-json
                  (comp (filter (fn [o] (> (:score o) 90)))
                        (take 10))
                  (fn [acc o] (conj acc o)) [] "events.json")
  (println "read failed:" e)
  (spit "top.json" (json/encode top)))

The shape is always the same — a transducer describing what to keep and a reducing function describing how to accumulate — whether the source is a vector in memory, a CSV file, a log, or a JSON array streamed off disk.

Building Web Services

The golisp/web package provides a Ring-style HTTP framework: handlers are plain functions, requests and responses are maps.

Handlers

A handler takes a request and returns a response:

(ns main (:import [golisp/web]))

(defn hello [req web/Request] -> web/Response
  (web/json-response 200 {"message" "hello"}))

web/Request and web/Response are map[string]any — inspect and build them with standard map operations.

Routing

defroutes defines a web/Handler from a list of routes. The verb macros (GET, POST, PUT, DELETE, PATCH) turn each body into a handler, the vector after the path binds named path parameters as locals, and req is in scope throughout. An optional :middleware clause wraps the whole chain (outermost first):

(defroutes app
  :middleware
  [web/wrap-logging web/wrap-recover web/wrap-cors web/wrap-json]
  (GET    "/"          [] (home req))
  (GET    "/users"     [] (web/json-response 200 (all-users)))
  (GET    "/users/:id" [id]
    (if-let [u (find-user id)]
      (web/json-response 200 u)
      (web/not-found "user not found")))
  (POST   "/users"     [] (create-user req))
  (DELETE "/users/:id" [id] (delete-user id)))

A raw (web/get path handler) still composes inside defroutes, which is handy for a route that needs its own per-route middleware:

(defroutes app
  (GET "/" [] (home req))
  (web/get "/export" (web/wrap export-handler web/wrap-auth)))

Under the hood the DSL expands to web/routes plus web/wrap. You can write that lower-level form directly when you don't want path-param binding:

(def app web/Handler
  (web/routes
    (web/get  "/"          home)
    (web/post "/users"     create-user)))

Request Helpers

(web/path-param  req "id")           ; URL parameter
(web/query-param req "page")         ; ?page=2
(web/header      req "content-type") ; request header
(web/body-map    req)                ; parsed JSON body as map

Response Helpers

(web/json-response 200 {:id 1 :name "Alice"})
(web/text-response 200 "plain text")
(web/html-response 200 "<h1>Hello</h1>")
(web/redirect "/login")
(web/no-content)
(web/bad-request    "missing field")
(web/unauthorized   "invalid token")
(web/not-found      "user not found")
(web/server-error   "internal failure")

Middleware

Wrap handlers with middleware using web/wrap. Middleware applies right-to-left:

(def app web/Handler
  (web/wrap
    (web/routes ...)
    web/wrap-logging    ; logs each request
    web/wrap-recover    ; catches panics
    web/wrap-cors       ; adds CORS headers
    web/wrap-json))     ; parses JSON bodies

Write your own middleware:

(defn wrap-auth [handler web/Handler] -> web/Handler
  (fn [req web/Request] -> web/Response
    (let [token (web/header req "authorization")]
      (if (valid-token? token)
        (handler (assoc req "identity" (parse-token token)))
        (web/unauthorized "invalid token")))))

HTML with Hiccup

Build HTML from nested vectors:

(web/html
  [:html
    [:head [:title "My App"]]
    [:body
      [:h1 {:class "title"} "Hello"]
      [:ul
        (map (fn [item] [:li item]) items)]]])

Starting the Server

(defn main [] -> void
  (let [port (sys/env "PORT" "8080")]
    (println "listening on :" port)
    (web/serve-graceful (str ":" port) app)))

web/serve-graceful handles OS signals (SIGTERM, SIGINT) and drains in-flight requests before shutting down.

Complete Example: Task API

(ns main (:import [golisp/web]))

(def tasks []any
  [{"id" "1" "title" "Buy groceries" "done" false}
   {"id" "2" "title" "Write code"    "done" true}])

(defn create-task [req web/Request] -> web/Response
  (if-let [body (web/body-map req)]
    (let [{title :title :- string} body]
      (if (= title "")
        (web/bad-request "title is required")
        (web/json-response 201 {"id" "new" "title" title "done" false})))
    (web/bad-request "invalid JSON body")))

(defroutes app
  :middleware
  [web/wrap-logging web/wrap-recover web/wrap-json]
  (GET "/tasks" []
    (switch (web/query-param req "done")
      "true"  (web/json-response 200 (filter (fn [t] (get t "done")) tasks))
      "false" (web/json-response 200 (filter (fn [t] (not (get t "done"))) tasks))
      :default (web/json-response 200 tasks)))
  (GET "/tasks/:id" [id]
    (if-let [task (some (fn [t] (if (= (get t "id") id) t nil)) tasks)]
      (web/json-response 200 task)
      (web/not-found (str "task " id " not found"))))
  (POST "/tasks" [] (create-task req)))

(defn main [] -> void
  (println "Tasks API on :4000")
  (web/serve-graceful ":4000" app))

Server-Sent Events and WebSocket

; SSE — pass a channel; the framework streams values as events
(defn events [req web/Request] -> web/Response
  (let [ch (chan string 10)]
    (go
      (doseq [i (range 5)]
        (send! ch (str "event " i))
        (time/sleep (* 500 time/Millisecond)))
      (close! ch))
    (web/sse-response ch)))

; WebSocket — handler receives in/out channels
(defn chat [req web/Request] -> web/Response
  (web/websocket
    (fn [req web/Request in (chan string) out (chan string)] -> void
      (for-chan [msg in]
        (send! out (str "echo: " msg))))))

Modules

GoLisp's module system maps onto Go modules. A module is a directory with a glisp.mod file.

glisp.mod

module github.com/myuser/myapp

require (
  github.com/myuser/utils v1.2.0
)

go-require (
  github.com/google/uuid v1.6.0
  github.com/jackc/pgx/v5 v5.7.2
)

require pulls in GoLisp modules. go-require pulls in Go packages for use via interop.

Initializing a Module

glisp mod init github.com/myuser/myapp

Creates a glisp.mod in the current directory.

Adding Dependencies

Add a GoLisp module:

glisp get github.com/myuser/utils@v1.2.0

Add a Go package:

glisp get -go github.com/google/uuid@v1.6.0

Namespaces

Every .glsp file declares a namespace with ns. The namespace becomes the Go package name:

(ns main)                           ; package main (the entry point)
(ns utils)                          ; package utils
(ns db (:import [golisp/web]))      ; imports the web framework

For external dependencies:

(ns server
  (:import [golisp/web])
  (:require [github.com/myuser/utils]))

Writing a Library

Functions are exported by using PascalCase names:

; utils/math.glsp
(ns utils)

;;; Returns the absolute value of n.
(defn Abs [n int] -> int
  (if (< n 0) (- n) n))

;;; Clamps n to the range [min, max].
(defn Clamp [n int min int max int] -> int
  (cond
    (< n min) min
    (> n max) max
    :else n))

Consuming a Library

Call exported functions with the package name prefix, but in lowercase:

(ns main
  (:require [github.com/myuser/utils]))

(defn main [] -> void
  (println (utils/abs -5))        ; calls utils.Abs(-5)
  (println (utils/clamp 15 0 10))) ; calls utils.Clamp(15, 0, 10)

The compiler converts utils/abs to utils.Abs automatically.

Multi-File Projects

Split a program across multiple files — they share a namespace:

myapp/
  main.glsp      ; (ns main)
  handlers.glsp  ; (ns main)
  models.glsp    ; (ns main)

Build the directory:

glisp build myapp/

All .glsp files with the same namespace compile into one Go package.

Publishing a Module

Create a glisp.mod, push to GitHub, tag a version:

git tag v1.0.0
git push origin v1.0.0

Users then glisp get github.com/youruser/yourlib@v1.0.0.

Testing

GoLisp has a built-in test system. Tests live alongside the code they test.

deftest

deftest groups assertions under a name:

(ns main)

(defn add [a int b int] -> int (+ a b))

(deftest add-test
  (assert= (add 1 2) 3)
  (assert= (add 0 0) 0)
  (assert= (add -1 1) 0))

Assert Forms

FormPasses when
(assert= actual expected)values are equal
(assert-true expr)expr is truthy
(assert-false expr)expr is falsy
(assert-nil expr)expr is nil
(assert-err expr)expr is a non-nil error

Running Tests

glisp test myfile.glsp
glisp test mydir/

Output:

PASS  add-test
1 test passed

On failure:

FAIL  add-test
  assert= failed: got 4, expected 3
1 test failed

Testing Patterns

Test edge cases with descriptive names:

(deftest grade-boundaries
  (assert= (grade 90) "A")
  (assert= (grade 89) "B")
  (assert= (grade 60) "D")
  (assert= (grade 59) "F"))

Test collections:

(deftest filter-test
  (assert= (filter even? [1 2 3 4 5]) [2 4])
  (assert= (filter odd?  [1 2 3 4 5]) [1 3 5])
  (assert= (filter even? []) []))

Philosophy

GoLisp tests are direct: set up inputs, call functions, assert outputs. No mocking framework, no test doubles — if your function needs a database, connect to a test database.

Keep tests small and specific. One deftest per behavior, not per function. Test the observable contract, not the implementation.

Appendix A: Built-in Reference

Arithmetic

FormDescription
(+ a b ...)Add
(- a b ...)Subtract
(* a b ...)Multiply
(/ a b)Divide
(mod a b)Remainder
(inc n)Add 1
(dec n)Subtract 1
(abs n)Absolute value
(min a b ...)Minimum
(max a b ...)Maximum

Comparison and Logic

FormDescription
(= a b)Equal
(not= a b)Not equal
(< a b), (> a b)Less / greater
(<= a b), (>= a b)Less-or-equal / greater-or-equal
(and a b ...)Logical and (short-circuit)
(or a b ...)Logical or (short-circuit)
(not x)Logical not
(nil? x)True if nil
(even? n), (odd? n)Parity check
(pos? n), (neg? n), (zero? n)Sign checks

Collections

FormDescription
(count coll) / (len coll)Number of elements
(first coll)First element
(last coll)Last element
(rest coll)All but first
(nth coll i)Element at index i
(conj coll x)Append x
(contains? coll x)Membership test
(empty? coll)True if empty
(reverse coll)Reversed copy
(sort coll)Sorted copy
(sort-by f coll)Sort by key function
(take n coll)First n elements
(drop n coll)All but first n
(flatten coll)Flatten nested collections
(distinct coll)Remove duplicates
(range n) / (range a b)Integer range
(repeat n x)Repeat x n times

Higher-Order Functions

FormDescription
(map f coll)Apply f to each element
(map-indexed f coll)Apply f to (index, element)
(for [x coll :when pred] expr)List comprehension
(filter pred coll)Keep elements where pred is truthy
(reduce f init coll)Fold with accumulator
(some pred coll)First truthy result of pred, or nil
(every? pred coll)True if pred is truthy for all
(keep f coll)map, removing nil results
(mapcat f coll)map then flatten
(group-by f coll)Map from key to matching elements
(frequencies coll)Map from element to count
(partition n coll)Chunks of size n
(take-while pred coll)Take while predicate holds
(drop-while pred coll)Drop while predicate holds
(sort-by f coll)Sort by key function
(min-by f coll) / (max-by f coll)Extremum by key

Function Utilities

FormDescription
(comp f g ...)Compose functions right-to-left
(partial f a ...)Fix some arguments
(complement pred)Negate a predicate
(juxt f g ...)Apply multiple functions, return vector
(apply f coll)Call f with collection as args
(identity x)Return x unchanged
(constantly x)Function that always returns x
(fnil f default)Replace nil arg with default

Threading and Debugging

FormDescription
(-> x form ...)Thread x as first argument
(->> x form ...)Thread x as last argument
(some-> x form ...)->, short-circuiting to nil on a nil step
(some->> x form ...)->>, short-circuiting to nil on a nil step
(cond-> x test form ...)-> through forms whose test is truthy
(cond->> x test form ...)->> through forms whose test is truthy
(as-> x name form ...)Thread x bound to name (any position)
(tap-> x form ...)->, printing each stage
(tap->> x form ...)->>, printing each stage
(pp x)Pretty-print x, return it unchanged
(time-it expr)Print elapsed time, return expr's value

Maps

FormDescription
(get m k)Value for key k
(get m k default)Value for key k, or default
(assoc m k v)Set key
(dissoc m k)Remove key
(merge m1 m2)Merge maps (m2 wins)
(keys m)All keys
(vals m)All values
(get-in m [k1 k2])Nested get
(assoc-in m [k1 k2] v)Nested set
(update-in m [k1 k2] f)Nested update
(update m k f)Apply f to value at k
(select-keys m [k1 k2])Keep only these keys
(rename-keys m {old new})Rename keys
(map-vals f m)Apply f to all values
(map-keys f m)Apply f to all keys
(reduce-kv f init m)Fold over key-value pairs
(zipmap keys vals)Build map from parallel sequences

Sets

FormDescription
(set coll)Convert to set
(union s1 s2)All elements in either
(intersection s1 s2)Elements in both
(difference s1 s2)Elements in s1 but not s2

Strings

The str/ namespace is the canonical string library — prefer it. str, string, and format are bare core forms; the other bare string functions (upper-case, split, …) are legacy aliases of the str/ surface, kept working for compatibility.

FormDescription
(str a b ...)Concatenate (all types)
(string x)Convert a single value to a string
(format fmt args...)Printf-style formatting
(str/upper s)Uppercase
(str/lower s)Lowercase
(str/trim s)Strip whitespace
(str/trim-start s) / (str/trim-end s)Strip leading / trailing whitespace
(str/blank? s)True if empty or whitespace
(str/starts-with? s prefix)Prefix check
(str/ends-with? s suffix)Suffix check
(str/includes? s sub)Substring check
(str/index-of s sub) / (str/last-index-of s sub)First / last index of sub
(str/replace s old new)Replace all occurrences
(str/replace-first s old new)Replace first occurrence
(str/split s sep)Split by separator
(str/join sep coll)Join with separator (separator first)
(str/repeat s n)Repeat s n times
(str/capitalize s)Uppercase first char, lowercase the rest
(str/pad-left s width) / (str/pad-right s width)Pad to width
(subs s start end?)Substring

Numbers

FormDescription
(int x)Convert to int
(float64 x)Convert to float64
(parse-int s)Parse string → [int error]
(parse-float s)Parse string → [float64 error]

Math

Go's math package is reached directly — (math/sqrt x), (math/floor x), (math/pow x y), (math/abs x), the constant math/pi, and so on. The math/ core namespace adds everyday helpers:

FormDescription
(math/clamp x lo hi)Constrain x to [lo, hi]
(math/sign x)-1, 0, or 1 by the sign of x
(math/gcd a b)Greatest common divisor
(math/lcm a b)Least common multiple
(math/round-to x places)Round to the given decimal places

I/O

FormDescription
(println args...)Print with newline
(print args...)Print without newline

File I/O

slurp/spit/lines are the glisp-native (clojure.core-style) forms; the lower-level forms remain available.

FormDescription
(slurp path)Read whole file → [string error]
(spit path content)Write content to file → error
(lines s)Split a string into []string lines
(read-file path)[string error]
(write-file path content)error
(append-file path content)error
(file-exists? path)bool
(list-dir path)[[]string error]
(mkdir path)error

Paths and Filesystem

FormDescription
(path/join & parts)Join with the OS separator
(path/dir p)Directory part
(path/base p)Last element (file name)
(path/ext p)Extension, including the dot
(path/clean p)Shortest equivalent path
(glob pattern)Paths matching a single-level shell pattern
(walk dir)Every regular file under dir, recursively

System and CLI

FormDescription
(sys/args)Command-line arguments (program name first)
(sys/env name) / (sys/env name default)Environment variable, with optional default
(sys/exit code)Exit the process
(cli/parse-opts args specs)Parse options → {:options :arguments :errors :summary}

Subprocess

Both forms return {:out :err :exit :ok}.

FormDescription
(proc/run cmd & args)Run a command directly (no shell)
(proc/sh command)Run via sh -c (pipes, globs, redirection)

JSON

FormDescription
(json/encode x)[string error]
(json/decode s)[any error]

CSV

Header-mapped: the first record is the header; each later record becomes a map[string]any. Both forms return [value error].

FormDescription
(csv/parse text)Parse CSV to a list of header-keyed maps
(csv/write rows)Write a sequence of maps as CSV

Transducers

Called with a single argument (no collection), map, filter, remove, keep, take, drop, take-while, and drop-while return a transducer. comp composes them (data flows left-to-right).

FormDescription
(map f), (filter pred), …The 1-arg form is a transducer
(transduce xform rf init coll)Reduce coll, items transformed through xform
(sequence xform coll)Apply xform, return a vector
(into to xform coll)Pour transformed items into to

Line- and JSON-Oriented I/O

transduce-lines/transduce-json stream through a transducer in constant memory; take/take-while stop reading early. All return [value error].

FormDescription
(read-lines path)A file's lines as a vector → [[]any error]
(transduce-lines xform rf init path)Stream a file's lines through xform + rf
(transduce-json xform rf init path)Stream a top-level JSON array's elements

HTTP Client

FormDescription
(http/get url)GET request
(http/post url body)POST request
(http/put url body)PUT request
(http/delete url)DELETE request
(http/request opts)Custom request

Regex (RE2)

FormDescription
(re/match pattern s)bool
(re/find pattern s)First match or nil
(re/find-all pattern s)All matches
(re/replace pattern s repl)Replace matches
(re/split pattern s)Split by pattern

Concurrency

FormDescription
(go body...)Spawn goroutine
(go-val T body...)Goroutine returning chan T
(par expr...)Run in parallel, wait for all
(chan T)Unbuffered channel
(chan T n)Buffered channel
(send! ch val)Send value
(recv! ch)Receive value
(recv-ok! ch)Receive with close detection [val ok]
(close! ch)Close channel
(for-chan [x ch] body...)Range over channel
(select! clauses...)Select on channels
(pipeline [x src] stage...)Chain goroutine stages, returns a channel
(fan-out n [item ch] body...)n workers draining ch, blocks until done
(fan-in ch1 ch2 ...)Merge channels into one
(with-lock mu body...)Mutex critical section
(with-open [x res ...] body...)Bind resources, Close on exit (LIFO)
(doto x form...)Run side-effecting forms on x, return x
(defer expr)Defer to function end

Atoms

FormDescription
(atom v)Create a thread-safe atom holding v
(atom T v)Typed atom; deref returns a concrete T
(swap! a f)Atomically apply f, return new value
(reset! a v)Set value unconditionally
(deref a)Read current value

Context

FormDescription
(ctx/background)Background context
(ctx/with-cancel ctx)[context cancel]
(ctx/with-timeout ctx ms)[context cancel]
(ctx/cancel! cancel)Cancel a context
(ctx/done? ctx)True if context cancelled
(ctx/value ctx key)Context value
(ctx/with-value ctx key val)Context with value

Logging

FormDescription
(log/info msg k v ...)Structured info log
(log/debug msg k v ...)Debug log
(log/warn msg k v ...)Warning log
(log/error msg k v ...)Error log

Errors

FormDescription
(error msg)Create error
(wrap-error msg err)Wrap error with context
(errors/is? err target)Check error type
(panic msg)Panic
(recover)Catch panic (in deferred fn)
(assert cond)Panic if falsy
(assert cond msg)Panic with message if falsy

Appendix B: Tooling

glisp CLI

glisp <command> [args]
CommandDescription
run file.glsp [args]Compile and run, no output files
run --watch file.glspRe-run on every save
file.glsp [args]Run directly (also via a #!/usr/bin/env glisp shebang)
build file.glspCompile to binary
build dir/Compile all .glsp files in directory
compile file.glspWrite generated .go file to disk
print file.glspPrint generated Go to stdout
macroexpand file.glspPrint the file with macros expanded
fmt file.glspFormat file in-place
fmt --check file.glspCheck formatting, exit 1 if needed
test file.glspRun deftest blocks
test dir/Run all tests in directory
doc [name]Show built-in documentation
mod init [module-path]Initialize a glisp.mod
get module[@version]Add GoLisp dependency
get -go module[@version]Add Go dependency
versionShow compiler version

Formatter

glisp fmt formats .glsp files in place. It:

  • Preserves comments (;, ;;)
  • Preserves docstrings (;;;)
  • Keeps trailing inline comments on let/loop/with-open bindings in place
  • Aligns map values
  • Breaks long collections across lines
  • Is round-trip safe — formatting is idempotent

Run as a pre-commit step:

glisp fmt --check .

Language Server (LSP)

The install.sh script installs glisp-lsp alongside glisp. To build it from source instead:

go install github.com/leinonen/golisp-language/cmd/glisp-lsp@latest

Features:

  • Hover: function signatures and type info
  • Jump to definition
  • Completions: built-ins, user functions, web types
  • Find all references
  • Rename symbol
  • Document outline
  • Diagnostics on parse errors

Neovim Setup

Install via Mason or manually. Add to your Neovim config:

vim.api.nvim_create_autocmd("FileType", {
  pattern = "glsp",
  callback = function()
    vim.lsp.start({
      name = "glisp-lsp",
      cmd = { "glisp-lsp" },
      root_dir = vim.fn.getcwd(),
    })
  end,
})

Set the file type for .glsp files:

vim.filetype.add({ extension = { glsp = "clojure" } })

Clojure syntax highlighting works well for GoLisp.

VS Code

There is no dedicated GoLisp extension yet. Configure manually using Clojure syntax highlighting and the language server:

{
  "files.associations": { "*.glsp": "clojure" },
  "lsp.servers": {
    "glisp-lsp": {
      "command": ["glisp-lsp"],
      "filetypes": ["clojure"],
      "rootPatterns": ["glisp.mod"]
    }
  }
}

Inspecting Generated Go

When something behaves unexpectedly, look at what the compiler emits:

glisp print myfile.glsp

This prints the Go source. It's often the fastest way to understand a type error or an unexpected result.

Docker Deployment

GoLisp produces static binaries. A minimal Dockerfile:

FROM golang:1.25-alpine AS build
WORKDIR /app
COPY . .
RUN go install github.com/leinonen/golisp-language/cmd/glisp@latest
RUN glisp build main.glsp

FROM alpine:latest
COPY --from=build /app/main /app/main
ENTRYPOINT ["/app/main"]

The final image contains only the binary — no Go runtime, no interpreter.