README
¶
let-go
Greetings loafers! (λ-gophers haha, get it?)
This is a bytecode compiler and VM for a language closely resembling Clojure, a Clojure dialect, if you will. Ships as a single ~9MB binary with ~12ms startup time.
Here are some nebulous goals in no particular order:
- Quality entertainment,
- Making it legal to write Clojure at your Go dayjob,
- Implement as much of Clojure as possible — including persistent data types, true concurrency, transducers, core.async, and BigInts,
- Provide comfy two-way interop for arbitrary functions and types,
- AOT (let-go -> standalone binary) would be nice eventually,
- Stretch goal: let-go bytecode -> Go translation.
Here are the non goals:
- Stellar performance (cough cough, it seems to be way faster than Joker),
- Being a drop-in replacement for clojure/clojure at any point,
- Being a linter/formatter/tooling for Clojure in general.
Feature overview
Language
- Macros with syntax-quote, unquote, unquote-splicing
- Destructuring (sequential, associative,
:keys,:as,:or) - Multi-arity and variadic functions
loop/recurwith tail-call optimizationtry/catch/finally,throw,ex-infoletfnfor mutual recursion between local functions- Dynamic variables with
binding - Lazy sequences (
lazy-seq,iterate,repeat,cycle) - Transducers (
map,filter,take,drop,partition-by, etc. all return transducers with 1-arity) transduce,intowith xform,completing,sequence,cat,dedupe- Protocols and
extend-type/extend-protocol - Records with
defrecord - Multimethods with
defmulti/defmethod - Regular expressions (Go flavor)
- Metadata on collections and vars
Data structures
- Persistent hash maps (HAMT), vectors, sets
- Transient collections for efficient batch building
delay/force,promise/deliveratomwith watches,volatile!for unsynchronized mutationreducedfor early termination inreduce/transduce
Concurrency (async namespace)
goblocks andgo-loop— goroutine-based lightweight concurrency- Channels with optional buffering,
<!,>!,close! alts!— select on multiple channel operations with timeout supportoffer!/poll!— non-blocking channel opsmult/tap/untap— broadcastpub/sub/unsub— topic-based routingmerge,pipe,split,async/map,async/taketo-chan!,onto-chan!,async/into,async/reducepromise-chan,timeout
IO & Networking (io namespace)
- Protocol-based reader/writer coercion (
IReadable,IWritable) io/reader,io/writer— polymorphic (strings as paths, handles, buffers, URLs)io/line-seq— lazy line-by-line readingio/buffer— mutable byte buffersio/copy,io/slurp,io/spit,io/read-lines,io/write-linesio/url— parsed URL records, readable via protocol (HTTP GET)- Encoding:
io/encode/io/decode(:base64,:hex,:url) - Handle-based file IO:
open,close!,read-line,write!,read-bytes with-openmacro for auto-closing resources*in*,*out*,*err*— stdin/stdout/stderr
HTTP (http namespace)
- Ring-style HTTP server (
http/serve) - HTTP client:
http/get,http/post,http/request - Streaming responses with
:as :stream - URL records accepted in all client functions
JSON (json namespace)
json/read-json,json/write-json- Proper float preservation, PersistentMap/Vector support, record serialization
Transit (transit namespace)
transit/read,transit/write- transit+json codec- Full rolling cache support for compact encoding
- Keywords, symbols, maps, vectors, sets, lists, big integers
OS (os namespace)
os/sh- run shell commands, capture stdout/stderr/exit codeos/stat,os/ls,os/cwd,os/getenv,os/setenv,os/exit
Babashka pods
let-go supports Babashka pods - standalone programs that expose namespaces over a binary protocol. This gives let-go access to the entire pod ecosystem: databases, AWS, Docker, file watching, and more.
;; Load a pod (uses babashka's shared cache)
(pods/load-pod 'org.babashka/go-sqlite3 "0.3.13")
;; Use it like any other namespace
(pod.babashka.go-sqlite3/execute! "app.db"
["create table users (id integer primary key, name text)"])
(pod.babashka.go-sqlite3/execute! "app.db"
["insert into users values (1, ?)" "Alice"])
(pod.babashka.go-sqlite3/query "app.db"
["select * from users"])
;; => [{:id 1 :name "Alice"}]
pods/load-pod- load by name (PATH) or from babashka cache (symbol + version)- Supports JSON, EDN, and transit+json payload formats
- Client-side code evaluation (pod-defined macros and wrappers)
- Async streaming via
pods/invokewith:handlersfor callbacks - Shares
~/.babashka/pods/cache - install pods withbb, use them fromlg
See the pod registry for available pods. Install pods with babashka:
bb -e '(pods/load-pod (quote org.babashka/go-sqlite3) "0.3.13")'
Go interop
RegisterStruct[T]— map Go structs to let-go records with cached field convertersToRecord[T]/ToStruct[T]— zero-cost roundtrip for unmutated recordsBoxValueauto-converts registered structs to records- Boxed Go values expose methods via
.methodinterop syntax .fieldaccess on records
Core library
Comprehensive clojure.core coverage including:
comp, partial, juxt, complement, constantly, memoize, trampoline,
map, filter, reduce, mapcat, keep, take, drop, take-while, drop-while,
group-by, frequencies, partition, partition-by, interpose, interleave,
flatten, distinct, dedupe, sort-by, merge-with, select-keys, update-in,
get-in, assoc-in, tree-seq, cycle, doall, dorun, pmap,
future, promise, deliver, add-watch, remove-watch, subvec,
compare, not-any?, not-every?, doto, fn?, replace, nthrest, nthnext,
bit-and, bit-or, bit-xor, bit-not, bit-shift-left, bit-shift-right,
re-find, re-matches, re-seq, re-groups, and many more.
Additional namespaces: string, set, walk, edn, pprint, test, transit, pods.
Benchmarks
Benchmarks compare let-go against Babashka (GraalVM native),
Joker (Go tree-walk interpreter), and Clojure on the JVM.
Each benchmark is valid Clojure that runs unmodified on all runtimes.
Run benchmark/run.sh to reproduce (requires hyperfine, bb, clj, joker).
| let-go | babashka | joker | clojure JVM | |
|---|---|---|---|---|
| Platform | Go bytecode VM | GraalVM native | Go tree-walk interpreter | JVM (HotSpot) |
| Binary size | 9.2M | 68M | 26M | 304M (JDK) |
| Startup | 12ms | 21ms | 11ms | 346ms |
| Idle memory | 16MB | 27MB | 21MB | 93MB |
Performance highlights (Apple M1 Pro):
- Smallest footprint - 7x smaller than Babashka, 33x smaller than the JDK
- Fastest startup - 12ms, neck and neck with Joker, 1.8x faster than Babashka, 30x faster than JVM
- Wins on short-lived tasks - map/filter and transducer pipelines: 13ms vs bb's 21ms (startup dominates)
- Competitive on compute - fib(35) within 4% of Babashka (2.0s vs 1.9s), loop-recur neck and neck
- Lowest memory - 17MB for fib(35) vs bb's 77MB (4.5x less), 21MB for reduce 1M vs bb's 59MB (2.8x less)
- 10x faster than Joker on all compute benchmarks - bytecode VM vs tree-walk interpreter
Full results with methodology: benchmark/results.md
Known limitations and divergence from Clojure
Not implemented
- Sorted collections (
sorted-map,sorted-set) - Refs / STM — atoms + channels cover practical concurrency needs
- Agents — use
goblocks and channels instead - Chunked sequences — lazy seqs are unchunked (simpler, slightly different perf characteristics)
- Reader tagged literals (
#inst,#uuid) deftype— usedefrecordinsteadreify— protocols can only be extended to named types- Spec — no
clojure.spec alter-var-root— vars are mutable but noalter-var-root
Known behavioral differences
concat*(used internally by quasiquote) is eager — the user-facingconcatis lazy, matching Clojure- All channel operations block —
<!and<!!are identical (Go channels are always blocking), same for>!/>!! goblocks are real goroutines — no IOC (inversion of control) state machine like Clojure's core.async; this means they're cheaper butgoblocks can call blocking ops directly- No BigDecimal — numeric tower is
int64+float64+BigInt(no arbitrary-precision decimals) - Regex is Go flavor —
re2syntax, not Java regex letfnuses atoms internally for forward references — slight overhead vs Clojure's direct binding
Examples
See:
Try online
Check out this bare-bones online REPL. It runs a WASM build of let-go in your browser!
Installation
Homebrew (macOS / Linux)
brew tap nooga/let-go https://github.com/nooga/let-go
brew install let-go
Download binary
Grab a prebuilt binary from Releases — available for Linux, macOS, and Windows on amd64/arm64.
From source
Requires Go 1.22+.
go install github.com/nooga/let-go@latest
Usage
lg # REPL
lg -e '(+ 1 1)' # eval expression
lg myfile.lg # run file
lg -r myfile.lg # run file, then REPL
Building from source
go run . # run from source
go build -ldflags="-s -w" -o lg . # ~9MB stripped binary
nREPL
let-go includes an nREPL server compatible with CIDER (Emacs), Calva (VS Code), and Conjure (Neovim).
lg -n # start nREPL on default port (2137)
lg -n -p 7888 # start nREPL on port 7888
The server writes .nrepl-port in the current directory so editors auto-discover it.
Supported ops: clone, close, eval, load-file, describe, completions, complete, info, lookup, ls-sessions, interrupt
Emacs (CIDER): M-x cider-connect-clj, host localhost, port from .nrepl-port
VS Code (Calva): Open a let-go project — the included .vscode/settings.json registers a custom connect sequence. Use "Calva: Start a Project REPL and Connect (Jack-In)" and pick "let-go", or "Calva: Connect to a Running REPL Server" if the nREPL is already running.
Neovim (Conjure): Should auto-connect when .nrepl-port exists
Embedding in Go
import (
"github.com/nooga/let-go/pkg/api"
"github.com/nooga/let-go/pkg/vm"
)
c, _ := api.NewLetGo("myapp")
// Define Go values in let-go
c.Def("x", 42)
c.Def("greet", func(name string) string {
return "Hello, " + name
})
// Run let-go code
v, _ := c.Run(`(greet "world")`)
fmt.Println(v) // "Hello, world"
// Struct <-> Record interop
type Point struct { X, Y int }
vm.RegisterStruct[Point]("myapp/Point")
c.Def("p", Point{3, 4})
v, _ = c.Run(`(:x p)`) // 3
Testing
go test ./... -count=1 -timeout 30s
Documentation
¶
There is no documentation for this package.