README.adoc

July 16, 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] image:https://ci.outskirtslabs.com/repos/github/outskirtslabs/datastar-expressions/badge.svg[alt=nixbot,link=https://ci.outskirtslabs.com/repos/github/outskirtslabs/datastar-expressions]

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 (->js (set! $person-id ~(:id person)) (@post "/update-person"))}]

Use ->js when you want a composable expression object that stringifies to JavaScript, which is ideal for Hiccup attributes. Use ->js-str when you need an immediate string.

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 "431b9a007c07346792ff30d806845610b799e279"}

== Documentation

== 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]

== Composing Expressions

->js returns a small expression object. It stringifies to JavaScript, but it keeps the original Clojure form so you can unquote it into another expression without turning generated JavaScript into a string literal.

[source,clojure]

(ns user (:require [starfederation.datastar.clojure.expressions :refer [->js ->js-str]]))

;; Reuse a DOM expression in another expression. (let [input-value (->js (.. evt -target -value))] (str (->js (set! search input−value))));;=>"search ~input-value)))) ;; => "search = evt.target.value"

;; Reuse a predicate in a larger expression. (let [enter? (->js (= evt.key "Enter"))] (str (->js (when ~enter? (@post "/search"))))) ;; => "(((evt.key === "Enter")) ? ((@post("/search"))) : (null))"

;; Build expressions in small named steps. (let [input-value (->js (.. evt -target -value)) trimmed (->js (.trim ~input-value))] (str (->js (set! search trimmed))));;=>"search ~trimmed)))) ;; => "search = evt.target.value.trim()"

;; Plain strings still work, but they compose as string values. (let [input-value (->js (.. evt -target -value))] (->js-str (set! search (strinput−value))));;=>"search ~(str input-value)))) ;; => "search = "evt.target.value""

== Example Usage

[source,clojure]

(ns user (:require [starfederation.datastar.clojure.expressions :refer [->js ->js-str]]))

;; Samples ;; These examples use ->js-str so the REPL prints the generated JavaScript. ;; Prefer ->js for Hiccup attributes and for composing expressions.

(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] (->js-str (set! forty−two val)));;=>"forty-two ~val))) ;; => "forty-two = 42"

(let [val (random-uuid)] (->js-str (set! forty−two (strval))));;=>"forty-two ~(str val)))) ;; => "forty-two = "745a9225-890f-41a7-9fc4-008770a68e7e""

;; kebab case preservation (->js-str (set! record−id (:record−idrecord)));;=>"record-id ~(:record-id record))) ;; => "record-id = "1234""

;; actually... all case preservation :) (->js-str (set! recordid (:record−idrecord)));;=>"record_id ~(:record-id record))) ;; => "record_id = "1234""

(->js-str (set! recordId (:record−idrecord)));;=>"recordId ~(:record-id record))) ;; => "recordId = "1234""

;; namespaced signals work of course (->js-str (set! person.first−name"alice"));;=>"person.first-name "alice")) ;; => "person.first-name = "alice""

;; primitive functions work too (squint adds parens, but its ok) (let [val 1] (->js-str (set! forty−two(+ valforty-two (+ ~val forty-one)))) ;; => "forty−two=(1+forty-two = (1 + forty-one)"

;; calling js functions: (->js-str (pokeBear bear−id));;=>"pokeBear(bear-id)) ;; => "pokeBear(bear-id)"

;; actions (->js-str (@get "/poke")) ;; => "@get("/poke")"

(->js-str (@patch "/poke")) ;; => "@patch("/poke")"

;; expr with multiple statements are in order like you would expect (->js-str (set! bear−id1234)(pokeBearbear-id 1234) (pokeBear bear-id) (@post "/bear-poked")) ;; => "bear−id=1234;pokeBear(bear-id = 1234; pokeBear(bear-id); @post("/bear-poked")"

;; You can build dynamic signal names by using the signalinthefirstposition(let[field−name"name"](−>js−str(set!(signal in the first position (let [field-name "name"] (->js-str (set! (bear. ~field-name) "Yogi") (@post "/bear"))) ;; => "$bear.name = "Yogi"; @post("/bear")"

;; logical conjunctions and disjunctions (->js-str (and (= my−signal"bar")"ret−val"));;=>"((my-signal "bar") "ret-val")) ;; => "((my-signal === "bar")) && ("ret-val")"

;; But you should probably use when/if (->js-str (when (= my−signal"bar")"ret−val"));;=>"(((my-signal "bar") "ret-val")) ;; => "(((my-signal === "bar")) ? (("ret-val")) : (null))" (->js-str (if (= my−signal"bar")"true−val""false−val"));;=>"(((my-signal "bar") "true-val" "false-val")) ;; => "(((my-signal === "bar")) ? ("true-val") : ("false-val"))"

;; A few other variations (->js-str (&& (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 (->js-str (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 (->js-str {"hidden" (&& fetching−bears(=fetching-bears (= bear-id 1))}) ;; => "({"hidden": (fetching-bears) && ((bear-id === 1))})"

;; It also does edn->json conversion, so setting initial signals is possible (->js-str {:my-signal "init-value"}) ;; => "({"my-signal": "init-value"})"

(->js-str (let [value my−signal](printlnvalue)(and(=my-signal] (println value) (and (= my-signal "bear") (@post "/foo")))) ;; => "(() => { const value1 = my−signal;console.log((value1));return((my-signal; console.log((value1)); return ((my-signal === "bear")) && (@post("/foo")); })()"

;; JS template strings are supported ;; Since is used by the reader, we just wrap the whole thing in quotes (->js-str (@post ("/ping/evt.srcElement.id‘")));;=>"@post(‘/ping/{evt.srcElement.id}`"))) ;; => "@post(`/ping/{evt.srcElement.id}`)"

;; Negation (->js-str (not foo));;=>"(!(foo)) ;; => "(!(foo))" (->js-str (not (= 1 2))) ;; => "(!((1 === 2)))" (->js-str (not= (+ 1 3) 4)) ;; => "(!(((1 + 3) === 4)))" (->js-str (set! ui.leftnavOpen(notui._leftnavOpen (not ui._leftnavOpen))) ;; => "ui.leftnavOpen=(!(ui._leftnavOpen = (!(ui._leftnavOpen))"

;; if (->js-str (set! ui.leftnavOpen(ifui._leftnavOpen (if ui._leftnavOpen false true))) ;; => "ui.leftnavOpen=((ui._leftnavOpen = ((ui._leftnavOpen) ? (false) : (true))"

(->js-str (if ui.leftnavOpen(set!ui._leftnavOpen (set! ui._leftnavOpen false) (set! ui.leftnavOpentrue)));;=>"((ui._leftnavOpen true))) ;; => "((ui._leftnavOpen) ? (ui.leftnavOpen=false):(ui._leftnavOpen = false) : (ui._leftnavOpen = true))"

;; expr/raw is an escape hatch to emit raw JS ;; raw/1 emits its argument as is (->js-str (set! foo(expr/raw"!foo (expr/raw "!foo"))) ;; => "foo=!foo = !foo"

(let [we-are "/back-in-string-concat-land"] (->js-str (set! volume11)(expr/raw (str"window.location="we−are))));;=>"volume 11) (expr/raw ~(str "window.location = " we-are)))) ;; => "volume = 11; window.location = /back-in-string-concat-land"

;; raw/0 emits nothing (->js-str (set! foo(expr/raw)));;=>"foo (expr/raw))) ;; => "foo ="

;; bare symbols (->js-str ui.mainMenuOpen);;=>"ui._mainMenuOpen) ;; => "ui._mainMenuOpen"

;; when-not (->js-str (when-not (= 1 1) (set! ui.mainMenuOpentrue)));;=>"(((1===1))?(null):(ui._mainMenuOpen true))) ;; => "(((1 === 1)) ? (null) : (ui._mainMenuOpen = true))"

;; bare booleans (->js-str (when false (set! footrue)));;=>"((false)?((foo true))) ;; => "((false) ? ((foo = true)) : (null))"

== Known Limitations

[source,clojure]

;; a generated symbol (el-id below) cannot be used in a template string (->js-str (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}))}; })()"

;; No condp: Squint emits a larger helper expression and references squint_core. (->js-str (condp = ui.mainMenuOpentrue(set!ui._mainMenuOpen true (set! ui._mainMenuOpen false) false (set! $ui._mainMenuOpen true)))

== License: MIT License

Copyright © 2025 Casey Link casey@outskirtslabs.com

Distributed under the https://spdx.org/licenses/MIT.html[MIT].