Ubuntu TechHive
save-tokens-and-build-with-data-driven-clarity-using-clojure-script-part.md
Save Tokens and Build with Data-Driven Clarity using Clojure(Script)
article.detail

Save Tokens and Build with Data-Driven Clarity using Clojure(Script)

reading.progress 12 min read

Save Tokens and Build with Data-Driven Clarity using Clojure(Script)

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:

  • ::settings initialized first.
  • ::greeter received the initialized settings.
  • system is 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 fetch API and web standards
  • API inspired by ring