Skip to content
HN On Hacker News ↗

GitHub - nooga/let-go: Almost Clojure written in Go.

▲ 292 points 85 comments by marcingas 2w ago HN discussion ↗

Pangram verdict · v3.3

We believe that this document is primarily human-written, with some AI-generated content detected

36 %

AI likelihood · overall

Mixed
87% human-written 13% AI-generated
SEGMENTS · HUMAN 2 of 7
SEGMENTS · AI 0 of 7
WORD COUNT 1,671
PEAK AI % 61% · §2
Analyzed
May 9
backend: pangram/v3.3
Segments scanned
7 windows
avg 239 words each
Distribution
87 / 13%
human / AI fraction
Verdict
Mixed
Pangram v3.3

Article text · 1,671 words · 7 segments analyzed

Human AI-generated
§1 Human · 17%

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. The smallest and fastest-starting Clojure-family language in Go — a single ~10MB binary with ~7ms cold start. Why let-go?

Standalone executables — compile your program into a single binary with lg -b myapp main.lg. No runtime needed, just distribute and run. WASM web apps — compile your program to a self-contained HTML page with lg -w outdir main.lg. Full terminal emulation via xterm.js, runs in any browser. Deploy to GitHub Pages or open locally. Fast startup — 6ms cold start. Pre-compiled bytecode (LGB format) makes boot near-instant even with a large standard library. Small footprint — 10MB binary, 14MB idle memory. 7x smaller than Babashka, 30x smaller than JDK. Batteries included — core.async channels, HTTP server/client, JSON, Transit, IO, Babashka pods, nREPL server. Go interop — embed let-go in Go apps, map Go structs to records, call Go functions from let-go and vice versa. Broad Clojure compatibility — macros, destructuring, protocols, records, multimethods, transducers, lazy seqs, persistent data structures, BigInts.

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 compilation — compile let-go programs to bytecode or standalone binaries, Boot the entire runtime in a single requestAnimationFrame and still have 10ms to spare at 60fps, Compile let-go programs to self-contained WASM web apps with terminal emulation, nREPL server in WASM — connect Emacs/Calva to a let-go VM running in the browser via WebSocket, Stretch goal: let-go bytecode -> Go translation.

§2 Mixed · 61%

Here are the non goals:

Being a drop-in replacement for clojure/clojure at any point, Being a linter/formatter/tooling for Clojure in general.

Feature overview let-go aims to feel like day-to-day Clojure, not to be a drop-in replacement. Most idiomatic code reads, runs, and behaves the same — but a non-trivial Clojure project will likely need some adjustments before it runs unmodified. See Known limitations below. Clojure compatibility let-go is tested against jank-lang/clojure-test-suite, a cross-dialect compliance suite: 4696 / 4921 assertions pass (95.4%) across 217 test files. The remaining gaps are mostly edge cases (overflow detection on +/-/*/inc/dec, BigInt promotion at the Long boundary, BigDecimal behavior) and a handful of stub namespaces — see below. Workflow guide: docs/clojure-test-suite.md. Standard namespaces

Namespace Status

clojure.core Macros, destructuring, lazy seqs, transducers, protocols, records, multimethods, atoms, regex, metadata, BigInt

clojure.string Full

clojure.set Full

clojure.walk prewalk, postwalk, keywordize-keys, stringify-keys, walk

clojure.edn read, read-string

clojure.pprint pprint, cl-format

clojure.test deftest, is, testing, are, fixtures

clojure.core.async Channels, go/go-loop, alts!, mult/pub, pipe/merge/split (real goroutines, not IOC)

io Polymorphic readers/writers, slurp/spit, lazy line-seq, encoding, URLs, with-open

http Ring-style server + client, streaming responses

json read-json, write-json — float-preserving, record-aware

transit transit+json codec with rolling cache

os sh, stat, ls, cwd, getenv/setenv, exit, os-name, arch, user-name, hostname, separators

System JVM-shaped: getProperty, getProperties, getenv, exit, lineSeparator, currentTimeMillis, nanoTime.

§3 Mixed · 36%

Exposes let-go.version, let-go.commit, user.home, user.dir, os.name, os.arch, etc.

syscall Direct Linux syscalls (mount, unshare, mknod, prctl, capset, seccomp, AppArmor) for systems programming

pods Babashka pods over JSON / EDN / transit

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/invoke with :handlers for callbacks Shares ~/.babashka/pods/ cache - install pods with bb, use them from lg

See the pod registry for available pods.

§4 Mixed · 46%

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 converters ToRecord[T] / ToStruct[T] — zero-cost roundtrip for unmutated records BoxValue auto-converts registered structs to records Boxed Go values expose methods via .method interop syntax .field access on records

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 10M 68M 26M 304M (JDK)

Startup 7ms 20ms 12ms 331ms

Idle memory 14MB 27MB 21MB 92MB

Performance highlights (Apple M1 Pro):

Smallest footprint — 7x smaller than Babashka, 30x smaller than the JDK Fastest startup — 7ms with pre-compiled bytecode (fits in a requestAnimationFrame), 3x faster than Babashka, 2x faster than Joker, 48x faster than JVM Wins on short-lived tasks — map/filter and transducer pipelines: 8ms vs bb's 19ms (2.4x faster) Competitive on compute

§5 Mixed · 38%

— fib(35) within 4% of Babashka (1.98s vs 1.90s), loop-recur 1.8x faster Lowest memory — 14MB for fib(35) vs bb's 77MB (5.4x less), 20MB for reduce 1M vs bb's 59MB (3x less) 10x+ faster than Joker on most compute benchmarks — bytecode VM vs tree-walk interpreter

Full results with methodology: benchmark/results.md Known limitations and divergence from Clojure Not implemented

Refs / STM — atoms + channels cover practical concurrency needs Agents — use go blocks and channels instead Hierarchies (derive, underive, ancestors, descendants, parents) — stub only; multimethod dispatch works, but isa? chains do not with-precision — BigDecimal itself works (M literals, bigdec, decimal?, exact arithmetic), but with-precision is a no-op so explicit rounding control is missing Chunked sequences — lazy seqs are unchunked (simpler, slightly different perf characteristics) Reader tagged literals (#inst, #uuid) deftype — use defrecord instead reify — protocols can only be extended to named types Spec — no clojure.spec alter-var-root — vars are mutable but no alter-var-root Numeric overflow detection — +/-/*/inc/dec wrap silently on int64 overflow rather than promoting to BigInt; use +'/-'/*' for explicit BigInt math subseq / rsubseq — sorted collections themselves work (sorted-map, sorted-set, sorted-map-by, sorted-set-by, rseq), but range queries on them are not yet implemented

Known behavioral differences

concat* (used internally by quasiquote) is eager — the user-facing concat is lazy, matching Clojure All channel operations block — <! and <!! are identical (Go channels are always blocking), same for >!/

§6 Human · 27%

>!! go blocks are real goroutines — no IOC (inversion of control) state machine like Clojure's core.async; this means they're cheaper but go blocks can call blocking ops directly No BigDecimal — numeric tower is int64 + float64 + BigInt (no arbitrary-precision decimals) Regex is Go flavor — re2 syntax, not Java regex letfn uses atoms internally for forward references — slight overhead vs Clojure's direct binding

Examples Real projects written in let-go:

xsofy — a roguelike that runs in the browser and the terminal from the same source lgcr — a decent daemonless container runtime, built on the syscall namespace

In this repo:

examples/ — small programs test/ — .lg test files covering all features

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 lg -w outdir myfile.lg # compile to WASM web app Compilation and distribution let-go can compile programs to bytecode (.lgb files) and package them as standalone executables. Compile to bytecode — skips the reader/parser/compiler at load time: lg -c app.lgb app.lg # compile to bytecode lg app.lgb # run bytecode directly Create a standalone binary — bundles the compiled bytecode into a self-contained executable: lg -b myapp app.lg # compile + bundle into executable ./myapp # runs anywhere, no lg needed The standalone binary is a copy of lg with your program's bytecode appended. It needs no external files or runtime — just copy it to another machine and run it.

§7 Mixed · 38%

Build a WASM web app — compiles your program into a single HTML page that runs in the browser: lg -w site app.lg # compile to web app open site/index.html # open in browser The output directory contains:

index.html — self-contained (~6MB, inlined WASM + wasm_exec.js, gzip-compressed) coi-serviceworker.js — enables cross-origin isolation for interactive apps (needed on GitHub Pages)

Programs using the term namespace get full terminal emulation via xterm.js — ANSI colors, cursor positioning, raw keyboard input all work. The Go WASM runtime runs in a Web Worker with SharedArrayBuffer for blocking term/read-key. For GitHub Pages deployment, just point Pages at the output directory. The service worker handles the required COOP/COEP headers automatically. Detecting AOT compilation — the *compiling-aot* var is true during -c, -b, and -w compilation, false at runtime. Use it to prevent side effects (like starting a server or game loop) from running at compile time: (defn -main [] (start-server))

(when-not *compiling-aot* (-main)) Detecting WASM at runtime — the *in-wasm* var is true when running inside a WASM web app, false in native mode. Use it to disable file I/O, adjust animation timing, or enable browser-specific behavior: (when-not *in-wasm* (spit "debug.log" "only in native mode")) 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.