README.adoc
February 19, 2026 Β· View on GitHub
= datastar-expressions
Clojure to Datastar expression transpiler
image:https://img.shields.io/badge/doc-outskirtslabs-orange.svg[doc,link=https://docs.outskirtslabs.com/datastar-expressions/next/] image:https://img.shields.io/badge/status-experimental-red.svg[status: experimental,link=https://docs.outskirtslabs.com/open-source-vital-signs#experimental]
expressions is a proof-of-concept for writing https://data-star.dev[π
datastar] expressions using Clojure without manual string concatenation.
Instead of:
[source,clojure]
[:button {:data-on-click (str "$person-id" (:id person) " @post('/update-person')")}]`
Write this:
[source,clojure]
[:button {:data-on-click (->expr (set! $person-id ~(:id person)) (@post "/update-person"))}]
It is powered by https://github.com/squint-cljs/squint[squint], thanks https://github.com/borkdude[@borkdude].
Project status: https://docs.outskirtslabs.com/open-source-vital-signs#experimental[Experimental].
== Goal & Non-Goals
Since Clojure does not have string interpolation, writing even simple
https://data-star.dev/guide/datastar_expressions[Datastar (d++*++)
expressions] can involve a lot of str or format gymnastics.
The goal of expressions is to add a little bit of syntax sugar when
writing d++*++ expressions so that they can be read and manipulated as
s-expressions.
D++++ expressions are not exactly javascript, though they are interpreted by the js runtime. D++++ expressions also do not have a grammar or really any formal definition of any kind. Delaneyβs official position is that the simplest and obvious expressions a human would write should work.
expressions follows that by trying to provide coverage for 99% of
simple and obvious expressions.
β οΈ You can totally write expressions that result in broken javascript, that is not necessarily a bug.
== Install
[source,clojure]
datastar/expressions {:git/url "https://github.com/outskirtslabs/datastar-expressions/" :git/sha "53efc7093be1ba33b331b4a27884be8925d8bdce"}
== Documentation
- https://docs.outskirtslabs.com/datastar-expressions/next/[Docs]
- https://docs.outskirtslabs.com/datastar-expressions/next/api[API Reference]
- https://github.com/outskirtslabs/datastar-expressions/issues[Support via GitHub Issues]
== Status
expressions is experimental and breaking changes will occur as it is
actively being developed. Please share your feedback so we can squash
bugs and arrive at a stable release.
== REPL Exploration
To see what this is all about, you can clone this repo and play with the demos:
.... clojure -M:dev ;; (bring your own repl server) ....
Check out link:./dev/user.clj[dev/user.clj] and
link:./dev/demo.clj[dev/demo.clj]
== Example Usage
[source,clojure]
(ns user (:require [starfederation.datastar.clojure.expressions :refer [->expr]]))
;; Samples
(def record {:record-id "1234"})
;; You have to unquote (~) forms you want evaluated ;; Otherwise no quoting is needed! ;; vars and locals are available for evaluation (let [val 42] (->expr (set! forty-two = 42;"
(let [val (random-uuid)] (->expr (set! forty-two = "745a9225-890f-41a7-9fc4-008770a68e7e";"
;; kebab case preservation (->expr (set! record-id = "1234";"
;; actually... all case preservation :) (->expr (set! record_id = "1234";"
(->expr (set! recordId = "1234";"
;; namespaced signals work of course (->expr (set! person.first-name = "alice";"
;; primitive functions work too (squint adds parens, but its ok) (let [val 1] (->expr (set! forty-one)))) ;; => "forty-one);"
;; calling js functions: (->expr (pokeBear bear-id)"
;; actions (->expr (@get "/poke")) ;; => "@get("/poke")"
(->expr (@patch "/poke")) ;; => "@patch("/poke")"
;; expr with multiple statements are in order like you would expect (->expr (set! bear-id) (@post "/bear-poked")) ;; => "bear-id); @post("/bear-poked")"
;; You can build dynamic signal names by using the bear. ~field-name) "Yogi") (@post "/bear"))) ;; => "$bear.name = "Yogi";; @post("/bear")"
;; logical conjunctions and disjunctions (->expr (and (= my-signal) === ("bar")) && ("ret-val")"
;; But you should probably use when/if (->expr (when (= my-signal) === ("bar")) ? (("ret-val")) : (null))" (->expr (if (= my-signal) === ("bar")) ? ("true-val") : ("false-val"))"
;; A few other variations (->expr (&& (or (= evt.key "Enter") (&& evt.ctrlKey (= evt.key "1"))) (alert "Key Pressed"))) ;; => "(((evt.key) === ("Enter")) || ((evt.ctrlKey) && ((evt.key) === ("1")))) && (alert("Key Pressed"))"
;; This one is interesting, see how it uses the , operator to separate sub-expressions (->expr (when (= evt.key "Enter") (evt.preventDefault) (alert "Key Pressed"))) ;; => "(((evt.key) === ("Enter")) ? ((evt.preventDefault()), (alert("Key Pressed"))) : (null))"
;; And here is one for data-class (->expr {"hidden" (&& bear-id 1))}) ;; => "({ "hidden": (fetching-bears) && ((bear-id) === (1)) })"
;; It also does edn->json conversion, so setting initial signals is possible (->expr {:my-signal "init-value"}) ;; => "({ "my-signal": "init-value" })"
(->expr (let [value my-signal "bear") (@post "/foo")))) ;; => "(() => { const value1 = my-signal) === ("bear")) && (@post("/foo")); })()"
;; JS template strings are supported
;; Since is used by the reader, we just wrap the whole thing in quotes (->expr (@post ("/ping/{evt.srcElement.id}`)"
;; Negation (->expr (not foo))" (->expr (not (= 1 2))) ;; => "(!((1) === (2)))" (->expr (not= (+ 1 3) 4)) ;; => "((1) + (3)) !== (4)" (->expr (set! ui._leftnavOpen))) ;; => "ui._leftnavOpen))"
;; if (->expr (set! ui._leftnavOpen false true))) ;; => "ui._leftnavOpen) ? (false) : (true))"
(->expr (if ui._leftnavOpen false) (set! ui._leftnavOpen) ? (ui._leftnavOpen = true))"
;; expr/raw is an escape hatch to emit raw JS ;; raw/1 emits its argument as is (->expr (set! foo"))) ;; => "foo"
(let [we-are "/back-in-string-concat-land"] (->expr (set! volume = 11; window.location = /back-in-string-concat-land"
;; raw/0 emits nothing (->expr (set! foo ="
;; bare symbols (->expr ui._mainMenuOpen"
;; when-not (->expr (when-not (= 1 1) (set! ui._mainMenuOpen = true))"
;; bare booleans (->expr (when false (set! foo = true)) : (null))"
== Known Limitations
[source,clojure]
;; a generated symbol (el-id below) cannot be used in a template string
(->expr (let [el-id evt.srcElement.id]
(when el-id
(@post ("/ping/${el-id}")))))
;; => "(() => { const el_id1 = evt.srcElement.id; if (el_id1) { return @post(/ping/${el-id})} else { return alert("No id")}; })()"
;; No condp (->expr (condp = ui._mainMenuOpen false) false (set! ui._mainMenuOpen true))) ;; => "(() => { const pred__288831 = squint_core._EQ_; const expr__288842 = ui._mainMenuOpen; if (!!(pred__288831(true, expr__288842))) { return ui._mainMenuOpen = false} else { if (!!(pred__288831(false, expr__288842))) { return ui._mainMenuOpen = true} else { throw new java.lang.IllegalArgumentException(squint_core.str("No matching clause: ", expr__288842))}}; })()"
== License: MIT License
Copyright Β© 2025 Casey Link casey@outskirtslabs.com
Distributed under the https://spdx.org/licenses/MIT.html[MIT].