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
| Command | What it does |
|---|---|
glisp run file.glsp | Compile and run, no output files |
glisp run --watch file.glsp | Re-run on every save |
glisp file.glsp | Run directly (also works via a #!/usr/bin/env glisp shebang) |
glisp build file.glsp | Compile to a binary |
glisp build dir/ | Compile all .glsp files in a directory |
glisp fmt file.glsp | Format in-place |
glisp compile file.glsp | Emit the generated .go file |
glisp print file.glsp | Print generated Go to stdout |
glisp macroexpand file.glsp | Print the file with macros expanded |
glisp test file.glsp | Run 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 Functions — loop/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
| Syntax | Name | Meaning |
|---|---|---|
`form | syntax-quote | Build form as data (a template) |
~x | unquote | Splice the value of x into the template |
~@xs | unquote-splice | Splice the elements of sequence xs |
'form | quote | form 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
| Form | Passes 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
| Form | Description |
|---|---|
(+ 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
| Form | Description |
|---|---|
(= 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
| Form | Description |
|---|---|
(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
| Form | Description |
|---|---|
(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
| Form | Description |
|---|---|
(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
| Form | Description |
|---|---|
(-> 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
| Form | Description |
|---|---|
(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
| Form | Description |
|---|---|
(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.
| Form | Description |
|---|---|
(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
| Form | Description |
|---|---|
(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:
| Form | Description |
|---|---|
(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
| Form | Description |
|---|---|
(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.
| Form | Description |
|---|---|
(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
| Form | Description |
|---|---|
(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
| Form | Description |
|---|---|
(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}.
| Form | Description |
|---|---|
(proc/run cmd & args) | Run a command directly (no shell) |
(proc/sh command) | Run via sh -c (pipes, globs, redirection) |
JSON
| Form | Description |
|---|---|
(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].
| Form | Description |
|---|---|
(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).
| Form | Description |
|---|---|
(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].
| Form | Description |
|---|---|
(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
| Form | Description |
|---|---|
(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)
| Form | Description |
|---|---|
(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
| Form | Description |
|---|---|
(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
| Form | Description |
|---|---|
(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
| Form | Description |
|---|---|
(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
| Form | Description |
|---|---|
(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
| Form | Description |
|---|---|
(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]
| Command | Description |
|---|---|
run file.glsp [args] | Compile and run, no output files |
run --watch file.glsp | Re-run on every save |
file.glsp [args] | Run directly (also via a #!/usr/bin/env glisp shebang) |
build file.glsp | Compile to binary |
build dir/ | Compile all .glsp files in directory |
compile file.glsp | Write generated .go file to disk |
print file.glsp | Print generated Go to stdout |
macroexpand file.glsp | Print the file with macros expanded |
fmt file.glsp | Format file in-place |
fmt --check file.glsp | Check formatting, exit 1 if needed |
test file.glsp | Run 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 |
version | Show compiler version |
Formatter
glisp fmt formats .glsp files in place. It:
- Preserves comments (
;,;;) - Preserves docstrings (
;;;) - Keeps trailing inline comments on
let/loop/with-openbindings 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.