Coming from Go
You already know most of the runtime. cljgo compiles to plain Go source and then builds it with the Go toolchain, so what comes out is a single static binary that starts in milliseconds, needs no interpreter installed, and cross-compiles the way you’re used to.
What changes is the shape of the code: no structs, no methods, no
err != nil ladder. This page walks the four or five habits you’ll want to
swap, smallest first.
Start small: a struct is a map
Section titled “Start small: a struct is a map”Go:
type User struct { Name string Age int}
u := User{Name: "Harsh", Age: 30}fmt.Println(u.Name)cljgo:
(def user {:name "Harsh" :age 30})
(println (:name user))(println (:age user))Output:
Harsh30{...} is a map. :name is a keyword — a self-naming constant that is
also a function, so (:name user) reads the key. No type declaration, no
tags, no reflect to inspect it later.
Values don’t change — you make new ones
Section titled “Values don’t change — you make new ones”This is the one real mental shift. In Go you’d write u.Age = 31. Here you
build a new map:
(def user {:name "Harsh" :age 30})
(def older (assoc user :age 31))
(println older)(println user)Output:
{:name Harsh, :age 31}{:name Harsh, :age 30}assoc returned a new map; the original is untouched. That is not a copy
in the expensive sense — the two maps share their structure internally.
Why bother? Because the value you handed to another goroutine can never be mutated under you. Half the reason you reach for a mutex in Go disappears.
A method is just a function
Section titled “A method is just a function”Go:
func (u User) FullName() string { return u.First + " " + u.Last}cljgo — the receiver becomes an ordinary first argument:
(defn full-name [u] (str (:first u) " " (:last u)))
(println (full-name {:first "Harsh" :last "Iyer"}))Output:
Harsh IyerNo method set, no pointer-vs-value receiver question. Functions are free floating and take data.
An interface is a protocol
Section titled “An interface is a protocol”When you do want one name to behave differently per type, that’s a protocol — Go’s interface, with the dispatch written explicitly:
(defprotocol Shape (area [s]))
(defrecord Rect [w h] Shape (area [s] (* (:w s) (:h s))))
(defrecord Circle [r] Shape (area [s] (* 3.14159 (:r s) (:r s))))
(println (area (->Rect 3 4)))(println (area (->Circle 2)))Output:
1212.56636defrecord makes a named map type; ->Rect is its constructor. The
difference from Go: a protocol can be extended to a type you don’t own,
after the fact — no wrapper type needed.
Goroutines and channels are still goroutines and channels
Section titled “Goroutines and channels are still goroutines and channels”go blocks are not a simulation. A go block runs on a real goroutine and
chan is a real Go channel underneath:
(require '[clojure.core.async :as a])
(def results (a/chan 10))
(doseq [n [1 2 3 4]] (a/go (a/>! results (* n n))))
(def collected (sort (repeatedly 4 #(a/<!! results))))
(println collected)Output:
(1 4 9 16)Read the operators as arrows: >! puts onto a channel, <!! takes from
one. The !! version blocks the calling thread — that’s why we can use it
at the top level, outside a go block.
We sorted the results because, exactly like Go, four goroutines finish in whatever order they please.
err != nil, two ways
Section titled “err != nil, two ways”The direct translation is errors as values — return a map that says which happened:
(defn parse-port [s] (if-let [n (parse-long s)] {:ok n} {:error (str "not a number: " s)}))
(defn report [s] (let [{:keys [ok error]} (parse-port s)] (if error (println "skipping —" error) (println "listening on" ok))))
(report "8080")(report "eighty")(report "9090")Output:
listening on 8080skipping — not a number: eightylistening on 9090That {:keys [ok error]} is destructuring — it pulls both keys out in one
line, the same way a, err := does.
For the exceptional case — the one that should abort — use ex-info,
which is an error that carries a data map instead of a formatted string:
(defn parse-port! [s] (if-let [n (parse-long s)] n (throw (ex-info "bad port" {:input s}))))
(println (parse-port! "8080"))
(try (parse-port! "eighty") (catch Exception e (println (ex-message e)) (println (ex-data e))))Output:
8080bad port{:input eighty}ex-data gives you back the map, machine-readable. Use values for expected
failures, ex-info for the ones that mean “stop”.
Put it together: go build → cljgo build
Section titled “Put it together: go build → cljgo build”A single file compiles straight to a binary:
cljgo run hello.clj # like `go run`cljgo build -o hello hello.clj # like `go build`./helloOutput:
Hello from a static binary!That binary was 7.1 MB on this machine, links no evaluator, and runs
anywhere the Go toolchain can target — cljgo dist cross-compiles for
every platform in one go.
For a project, the build plan is build.cljgo — your go.mod plus your
Makefile, written in the same language as the app:
(defn build [b] (let [app (exe b {:name "newapp" :main "src/newapp/core.cljg"})] (install b app) (run b app)))And a third-party Go module is one line inside it, not a separate file:
(go-require app "github.com/gorilla/websocket" "v1.5.3")Same toolchain, same deployment story, less ceremony in between.
Ready for the language itself? Start at 1. Hello World and read straight through — it assumes nothing.
Questions or feedback on this page? Comment below with your GitHub account — comments are public and live in the project's GitHub Discussions.