Quick Story: Clojure(Script) and the Web
From early 2000s and beyond
- Learned Vim and Emacs but sucked at both
- HTTP and the Web
- From LAMP to SPAs and more Frameworks
- Lots of scripting and lots of metaprogramming (JavaScript, Ruby, Groovy, Scala ...)
- JavaScript went from toy to load bearing
LISP
- Lots of parens
- Intriguing and weird
- Wizzards wrote books (Practical Common Lisp, SICP ...)
- Heard lots of crazy stories about it (PG, HN, Hackers and Painters ...)
- Various LISP implementations all very strange
Practical LISP
- Found out Clojure at a Java Conference circa 2009
- Finally something relevant worth trying because of the JVM
- First serious attempt: Java Interface + Clojure Implementation in a Spring app
- It got better: a new Clojure on top of JavaScript (Google Closure Library, Google Closure Compiler: production grade aggressive minification)
- Progressive enlightenment: (verb data)
Clojure(Script): A Tool for Thinking clearly
- REPL
- Language adapts to domain
- Syntax? What syntax? It's all data structures
- Isomorphic
- Clojure is a hosted language (JVM, JavaScript, Dart and more)
- Get to the gist of it without the cruft
- Specs + functions + data + validation: (specked-verb data)
Clojure(Script) Basics
Each code block below is written so you can paste it into a ClojureScript REPL
as-is during a live demo.
In Lisp syntax, the first thing after an opening paren is the function:
(+ 1 2 3)
;; => 6
(str "Hello, " "ClojureScript")
;; => "Hello, ClojureScript"
Data literals: values first
Clojure(Script) code is mostly made from simple data shapes:
42 ;; number
"Ada" ;; string
:admin ;; keyword, often used as a data label
[1 2 3] ;; vector, ordered values
{:name "Ada" :visits 1} ;; map, keys to values
#{:cljs :repl :data} ;; set, unique values
Namespaces and require
At the REPL, use require to load a namespace and give it a short alias.
Real files use an ns form at the top, but require is friendlier for live
REPL demos.
(require ' [clojure.string :as str])
(str/upper-case "fast twitch")
;; => "FAST TWITCH"
(str/trim " hello ")
;; => "hello"
Keywords can also act like tiny lookup functions:
(def user {:name "Ada" :visits 1})
(:name user)
;; => "Ada"
(assoc user :active? true)
;; => {:name "Ada", :visits 1, :active? true}
(update user :visits inc)
;; => {:name "Ada", :visits 2}
Keys and namespaced keys
A map key is the label used to find a value. Keywords are common map keys
because they are small, readable, and work as lookup functions.
(def response
{:status 200
:body "ok"})
(get response :status)
;; => 200
(:body response)
;; => "ok"
Namespaced keywords add a prefix before the slash. They are still just
keywords, but the prefix helps avoid collisions in bigger data maps.
(def person
{:person/name "Ada"
:account/name "ada-lovelace"
:account/id 42})
(:person/name person)
;; => "Ada"
(:account/name person)
;; => "ada-lovelace"
(select-keys person [:person/name :account/id])
;; => {:person/name "Ada", :account/id 42}
You can inspect a keyword's namespace part and name part:
(namespace :account/id)
;; => "account"
(name :account/id)
;; => "id"
(keyword "account" "email")
;; => :account/email
Functions
defn gives a function a name. The vector after the name is the argument list.
(defn greet [name]
(str "Hello, " name "!"))
(greet "Ada")
;; => "Hello, Ada!"
Anonymous functions are useful when the function is small and local:
((fn [x] (* x 10)) 7)
;; => 70
(#(* % 10) 7)
;; => 70
Multi arity functions
One function name can support different numbers of arguments.
(defn greeting
([] (greeting "friend"))
([name] (str "Hello, " name))
([title name] (str "Hello, " title " " name)))
(greeting)
;; => "Hello, friend"
(greeting "Ada")
;; => "Hello, Ada"
(greeting "Dr." "Hopper")
;; => "Hello, Dr. Hopper"
This keeps simple defaults close to the full version.
map
map runs a function over each item in a collection. It returns a lazy sequence,
so use vec when you want to see a vector in the REPL.
(map inc [1 2 3])
;; => (2 3 4)
(vec (map inc [1 2 3]))
;; => [2 3 4]
(def todos
[{:title "Read the schema" :done? true}
{:title "Try the REPL" :done? false}])
(map :title todos)
;; => ("Read the schema" "Try the REPL")
->
The thread-first macro -> puts the previous result into the next form as the
first data argument. It makes left-to-right data cleanup easier to read.
(-> {:name "Ada" :visits 1}
(assoc :active? true)
(update :visits inc))
;; => {:name "Ada", :visits 2, :active? true}
Read it as: start with this map, then associate a value, then update a value.
->>
The thread-last macro ->> puts the previous result at the end of the next
form. It is common for collection pipelines.
(def todos
[{:title "Read the schema" :done? true}
{:title "Try the REPL" :done? false}])
(->> todos
(filter :done?)
(map :title)
vec)
;; => ["Read the schema"]
Read it as: take todos, keep done items, take their titles, make a vector.
Local names with let
let gives temporary names to values. These names only exist inside the let.
(let [title "Try the REPL"
done? false]
{:title title
:status (if done? "done" "open")})
;; => {:title "Try the REPL", :status "open"}
Destructuring
Destructuring pulls names out of maps and vectors without manual get calls.
(def user {:name "Ada" :visits 1})
(let [{:keys [name visits]} user]
(str name " visited " visits " time(s)"))
;; => "Ada visited 1 time(s)"
(let [ [first-item second-item & remaining] ["a" "b" "c" "d"]]
{:first first-item
:second second-item
:remaining remaining})
;; => {:first "a", :second "b", :remaining ("c" "d")}
This is especially handy for request maps:
(def request
{:request-method :get
:params {:id "42"}
:headers {"accept" "application/json"}})
(let [{:keys [request-method params]} request
{:keys [id]} params]
{:method request-method
:todo-id id})
;; => {:method :get, :todo-id "42"}
if, when, if-let, and when-let
if chooses between two values. Only false and nil are falsey in
Clojure(Script); 0, "", and empty collections are truthy.
(if (:done? {:title "Read" :done? true})
"Done"
"Open")
;; => "Done"
(if 0 "yes" "no")
;; => "yes"
when is for "do this only if the test is truthy"; otherwise it returns nil.
(when (seq "Ada")
"There is a name")
;; => "There is a name"
(when (seq "")
"This will not appear")
;; => nil
if-let and when-let bind a name only when the value is truthy. This pattern
appears often in route matching, cookie handling, and browser rendering.
(def request-with-user {:session {:user "Ada"}})
(if-let [user (get-in request-with-user [:session :user])]
(str "Signed in as " user)
"Please log in")
;; => "Signed in as Ada"
(when-let [title (get-in {:params {:todo {:title "Ship demo"}}}
[:params :todo :title])]
(str "Todo: " title))
;; => "Todo: Ship demo"
cond, case, and condp
Use cond when you have multiple tests. It checks them in order.
(defn todo-status [{:keys [done? blocked?]}]
(cond
blocked? "blocked"
done? "done"
:else "open"))
(todo-status {:done? false :blocked? true})
;; => "blocked"
Use case when one value is compared against known constants.
(defn method-action [request-method]
(case request-method
:get "read"
:post "write"
:delete "remove"
"unknown"))
(method-action :post)
;; => "write"
condp is less common in this repo, but useful when every branch uses the same
predicate.
(defn score-band [score]
(condp <= score
90 :great
75 :solid
:needs-practice))
(score-band 82)
;; => :solid
cond->
cond-> starts with a value and applies each step only when its test is truthy.
The project uses this for building request maps, headers, and middleware config.
(defn response-map [{:keys [body location]}]
(cond-> {:status 200
:headers {}}
body
(assoc :body body)
location
(assoc-in [:headers "Location"] location)))
(response-map {:body "ok" :location "/todos"})
;; => {:status 200, :headers {"Location" "/todos"}, :body "ok"}
some->
some-> is like ->, but it stops and returns nil as soon as a step returns
nil. This is useful when data may be missing.
(some-> {:pathname {:groups {:id "42"}}}
:pathname
:groups
:id)
;; => "42"
(some-> nil
:pathname
:groups
:id)
;; => nil
Nested maps: get-in, assoc-in, and update-in
Request maps and response maps are nested. These helpers avoid manual digging.
(def response {:status 200 :headers {}})
(assoc-in response [:headers "Content-Type"] "text/plain")
;; => {:status 200, :headers {"Content-Type" "text/plain"}}
(get-in {:params {:todo {:title "Learn get-in"}}}
[:params :todo :title])
;; => "Learn get-in"
(update-in {:todos {1 {:done? false}}}
[:todos 1 :done?]
not)
;; => {:todos {1 {:done? true}}}
State with atom, @, swap!, and reset!
Most data is immutable. When the app needs changing state, this repo uses
atoms, for example todos, counters, and browser UI state.
(def store
(atom {:clicks 0
:chips []}))
@store
;; => {:clicks 0, :chips []}
(swap! store update :clicks inc)
;; => {:clicks 1, :chips []}
(swap! store update :chips conj "REPL")
;; => {:clicks 1, :chips ["REPL"]}
(reset! store {:clicks 0 :chips []})
;; => {:clicks 0, :chips []}
In source files, this repo often uses defonce for browser or server state.
defonce means "define this only if it does not already exist", which is handy
during REPL reloads. In a live paste demo, plain def is less surprising
because it resets the example every time.
reduce
reduce walks through a collection while carrying an accumulator. The source
uses it to build maps, headers, and configuration.
(reduce
(fn [total n]
(+ total n))
0
[10 20 30])
;; => 60
(reduce
(fn [headers [k v]]
(assoc headers k v))
{}
[ [:content-type "text/plain"]
[:etag "v1"]])
;; => {:content-type "text/plain", :etag "v1"}
loop and recur
Use loop with recur when you need an explicit loop without mutable counters.
In many cases, map, filter, or reduce will be simpler.
(loop [n 3
result []]
(if (zero? n)
result
(recur (dec n) (conj result n))))
;; => [3 2 1]
JavaScript interop
ClojureScript is hosted on JavaScript, so we can call JavaScript directly when
we need the platform.
(.log js/console "Hello from ClojureScript")
;; prints in the JS console
(.toISOString (js/Date.))
;; => current time as an ISO string
(.round js/Math 4.6)
;; => 5
Use clj->js and js->clj at API boundaries:
(def json-text
(.stringify js/JSON (clj->js {:ok true :items [1 2 3]}) nil 2))
json-text
;; => "{\n \"ok\": true,\n \"items\": [\n 1,\n 2,\n 3\n ]\n}"
(js->clj (.parse js/JSON json-text) :keywordize-keys true)
;; => {:ok true, :items [1 2 3]}
For native JavaScript objects, property access is explicit:
(def js-user #js {:name "Ada" :visits 2})
(.-name js-user)
;; => "Ada"
(aget js-user "visits")
;; => 2
JavaScript promises
Many browser, Node, Deno, and Bun APIs return promises. This repo often uses
-> to make promise chains readable.
(-> (js/Promise.resolve "hello")
(.then (fn [text]
(.toUpperCase text)))
(.then (fn [loud]
(.log js/console loud)))
(.catch (fn [error]
(.error js/console error))))
;; logs "HELLO"; returns a Promise
try, catch, and throw
Use try when JavaScript or parsing code may throw. In ClojureScript,
(catch :default error ...) catches normal JavaScript errors.
(try
(.parse js/JSON "{bad json")
(catch :default error
(.-message error)))
;; => a JSON parse error message
(try
(throw (js/Error. "Boom"))
(catch :default error
(str "Caught: " (.-message error))))
;; => "Caught: Boom"
Macro basics
Macros transform code before it runs. We have already used macros: ->,
->>, when, cond->, and the repo's env-var / serve helpers.
(macroexpand-1
'(-> {:status 200}
(assoc :body "ok")))
;; => (assoc {:status 200} :body "ok")
(macroexpand-1
'(when true
"runs when true"))
;; => (if true (do "runs when true"))
In this repo, custom macros such as env-var and serve live in
src/fast_twitch/macros.cljc. For a live newbie demo, start with
macroexpand-1 on core macros before writing custom macros.
Once the idea lands, evaluate conditional threading normally:
(cond-> {:status 200}
true
(assoc :body "ok")
false
(assoc :debug true))
;; => {:status 200, :body "ok"}
Useful Libraries
For the library snippets, use a project or example that has the dependency on
the classpath. In this repo, examples/07-json-api-malli-openapi already has
both Malli and Integrant.
Malli
Malli lets us describe data with data. A schema is just a Clojure(Script) value
that we can validate, explain, transform, or reuse for docs.
(require ' [malli.core :as m])
(require ' [malli.error :as me])
(def TodoCreate
[:map
{:closed true}
[:title [:string {:min 1}]]
[:done {:optional true} :boolean]])
(m/validate TodoCreate {:title "Learn Malli" :done false})
;; => true
(m/validate TodoCreate {:title "" :extra "not allowed"})
;; => false
(me/humanize
(m/explain TodoCreate {:title "" :extra "not allowed"}))
;; => {:title ["should be at least 1 character"],
;; :extra ["disallowed key"]}
Use m/validator when you want a reusable validation function:
(require ' [malli.core :as m])
(def TodoCreate
[:map
{:closed true}
[:title [:string {:min 1}]]
[:done {:optional true} :boolean]])
(def valid-todo? (m/validator TodoCreate))
(valid-todo? {:title "Ship the demo"})
;; => true
(valid-todo? {:done true})
;; => false
The important idea for beginners: instead of hiding validation rules in many
conditionals, we put the shape of valid data in one small data value.
Integrant
Integrant starts an app from a config map. Each top-level key describes one
part of the system, and ig/ref says one part depends on another.
(require ' [integrant.core :as ig])
(defmethod ig/init-key ::settings [_ options]
options)
(defmethod ig/init-key ::greeter [_ {:keys [settings]}]
(fn [name]
(str (:prefix settings) ", " name "!")))
(def config
{::settings {:prefix "Hello"}
::greeter {:settings (ig/ref ::settings)}})
(def system (ig/init config))
((::greeter system) "Ada")
;; => "Hello, Ada!"
What happened:
::settingsinitialized first.::greeterreceived the initialized settings.systemis a map of initialized parts.
Add cleanup with ig/halt-key! for parts that own resources:
(require ' [integrant.core :as ig])
(defmethod ig/init-key ::store [_ initial-state]
(atom initial-state))
(defmethod ig/halt-key! ::store [_ store]
(reset! store {}))
(def store-system
(ig/init {::store {:todos []}}))
@(::store store-system)
;; => {:todos []}
(ig/halt! store-system)
;; => ignore the return value; halt! is for side effects
The important idea for beginners: keep the wiring as data, and keep startup and
shutdown behavior in small methods attached to those data keys.
FastTwitch
Source: FastTwitch ππ¨
- Web backend on top of major JavaScript runtimes
- Capture what's actually needed to do web backend stuff
- Several attempts
- ExpressJs
- Fastify
- Now just
fetchAPI and web standards - API inspired by
ring
