| Status | Date | Related |
|---|---|---|
| Implemented | 2026-02-06 | Inspired by core.async ioc_macros |
Users want async/await syntax for SCI in ClojureScript to simplify working with Promises.
Writing Promise-based code with nested .then chains is verbose. JavaScript and other languages provide async/await syntax that allows writing asynchronous code in a more sequential style.
The clojure.core.async library transforms go blocks into state machines via SSA (Static Single Assignment) form. See ~/dev/core.async/src/main/clojure/clojure/core/async/impl/go.clj. Key insights:
let, if, do, loop, etc.)item-to-ssa multimethod dispatching on :op<!, >!)For our implementation, we use a simpler approach: direct code transformation without full SSA, targeting Promise .then chains.
Transform async function bodies at analysis time into Promise .then chains.
(defn ^:async foo []
(let [x (await (js/Promise.resolve 1))]
(inc x)))
;; Transforms to:
(defn foo []
(.then (js/Promise.resolve 1) (fn [x] (inc x))))
Supported features:
^:async metadata on defn and fn formsawait recognized syntactically (just the symbol, no var/require needed)Supported special forms:
let* (and let via macro expansion)doifloop*/recurtry/catch/finallycase* (and case via macro expansion)quote (passed through unchanged)fn* (not descended into — nested fns are handled by the analyzer separately)Macros:
await or recur calls->, ->>, when, cond, doseq, etc. work automaticallyctx :bindings so macros see them in &env (e.g., a macro checking if -> is locally bound)For performance, the transformation emits calls to helper functions in sci.impl.async-await rather than raw JS interop. These helpers:
promise-form? detection;; In src/sci/impl/namespaces.cljc (CLJS only)
(defn promise-resolve [v] (js/Promise.resolve v))
(defn promise-then [p f] (.then p f))
(defn promise-catch [p f] (.catch p f))
(defn promise-catch-for-try [p f]
(.catch p (fn [e]
(f (if (= :sci/error (:type (ex-data e)))
(or (ex-cause e) e)
e)))))
(defn promise-finally [p f] (.finally p f))
The namespace is registered as sci.impl.async-await (internal, not for user consumption).
The core insight is using promise-form? to detect which subexpressions produce promises:
(defn- promise-form?
"Check if form is already a promise-producing expression.
Detects metadata marker or calls to sci.impl.async-await helpers."
[form]
(or (:sci.impl/promise (meta form))
(and (seq? form)
(let [op (first form)]
(and (symbol? op)
(= "sci.impl.async-await" (namespace op)))))))
Forms that produce promises but aren’t direct helper calls (like if with promise branches) are marked with metadata via (vary-meta form assoc :sci.impl/promise true). This avoids needing a noop wrapper macro.
When transforming expressions:
promise-form?This avoids needing a separate “contains await” check and handles macro expansion naturally.
All examples below use sip as an alias for sci.impl.async-await for brevity.
For let*:
(let* [x 1
y (await p1)
z (await p2)]
(+ x y z))
;; Transforms to:
(let* [x 1]
(sip/then (sip/resolve p1)
(fn [y]
(sip/then (sip/resolve p2)
(fn [z]
(+ x y z))))))
For do:
(do
(await p1)
(await p2)
result)
;; Transforms to:
(sip/then (sip/resolve p1)
(fn [_]
(sip/then (sip/resolve p2)
(fn [_]
result))))
For if:
(if (await p)
(await then-p)
(await else-p))
;; Transforms to:
(sip/then (sip/resolve p)
(fn [test__123]
(if test__123
(sip/resolve then-p)
(sip/resolve else-p))))
Key points:
For loop*/recur:
Loops with await are transformed into recursive promise-returning functions, wrapped in let* to preserve sequential scoping:
(loop [x 0]
(if (< x 3)
(do (await (js/Promise.resolve x))
(recur (inc x)))
x))
;; Transforms to:
(let* [x 0]
((fn loop_fn__123 [x]
(if (< x 3)
(sip/then (sip/resolve x)
(fn [_] (loop_fn__123 (inc x))))
(sip/resolve x)))
x))
Key points:
(loop* ...) unchanged (no promise overhead)let* so each sees previous bindings (important when a binding shadows a macro like ->)recur is handled post-macro-expansion in transform-async-body via :recur-target in ctx — this means macros that expand to recur work correctlytransform-expr-with-await to chain promisesFor try/catch/finally:
(try
(await p)
(catch js/Error e
(handle e))
(finally
(cleanup)))
;; Transforms to:
(sip/finally
(sip/catch-for-try
(sip/then (sip/resolve nil) (fn [_] (sip/resolve p))) ;; Body wrapped in .then
(fn [e] (handle e))) ;; catch-for-try unwraps SCI error wrapping
(fn [] (cleanup)))
Key points:
.then callback so synchronous throws are caught by .catchcatch-for-try instead of plain catch — this unwraps SCI’s error wrapping (rethrow-with-location-of-node adds {:type :sci/error} ex-data and wraps the original as ex-cause) so that user code sees the original exception with its ex-data preservedFor case*:
Important: Match constants are NOT transformed, only test expression and result expressions:
(case (await p)
1 :one
2 :two
:default)
;; Transforms to:
(sip/then (sip/resolve p)
(fn [case_test__123]
(case* case_test__123
1 :one
2 :two
:default)))
In src/sci/impl/analyzer.cljc, the analyze-fn* function detects :async metadata and calls the transformer:
;; Check for :async metadata
async? (or (:async fn-expr-m) (:async bodies-m))
;; If async, transform the body before analysis
body (if async?
(async-macro/transform-async-fn-body ctx locals body)
body)
| File | Description |
|---|---|
src/sci/impl/async_macro.cljc |
Transformation functions (~420 lines) |
src/sci/impl/namespaces.cljc |
Helper functions + sci.impl.async-await namespace registration |
src/sci/impl/analyzer.cljc |
~10 lines added for async detection |
test/sci/async_await_test.cljs |
Comprehensive test suite |
Run with: script/test/node
Test cases cover:
[-> fn x (-> 1)])The transformation emits (sci.impl.async-await/then ...) instead of (.then ...) for two reasons:
Performance: Helper functions are native CLJS that compile to direct JS interop, avoiding SCI’s interpreted method dispatch overhead. This matters for tight loops with many awaits.
Reliable detection: Using our own symbols as markers for promise-form? is more robust than checking for generic .then syntax. Users might write .then directly in their code, which shouldn’t be mistaken for transformed await calls.
promise-form? detection instead of contains-await?Initially we tried tracking whether expressions contain await calls. This had issues:
Using promise-form? on transformed results:
sci.impl.async-await/resolve?This ensures non-promise values work correctly:
(await 42) ;; Works - 42 is wrapped in Promise.resolve
Direct translation to recursive calls is simpler than state machines and works well with promises. The browser’s event loop handles “stack overflow” naturally since each .then callback runs in a fresh stack frame.
Forms like (if test then-with-await else) produce promises but aren’t direct helper calls. We need to mark them as promise-producing for containing expressions. Using metadata ^{:sci.impl/promise true} is cleaner than a noop wrapper macro because:
Initially recur was replaced with recursive function calls in a separate replace-recur pass before transformation. This had two problems:
(defmacro my-recur [& args] (cons 'recur args)) wouldn’t be expanded yet during the pre-pass, so the recur would be missed.(quote ...) forms.By handling recur inline in transform-async-body (after macro expansion), both issues are solved naturally. The loop passes :recur-target through ctx, and when transform-async-body encounters recur, it replaces it with a call to the loop function. Since recur args may contain await, they are processed via transform-expr-with-await.
catch-for-try instead of plain catch?SCI’s rethrow-with-location-of-node wraps exceptions with {:type :sci/error} ex-data and stores the original as ex-cause. In synchronous try/catch, the dynamic var *in-try* prevents this wrapping. But dynamic bindings don’t persist across .then chains, so async throws get wrapped.
catch-for-try is a runtime function that unwraps this: if the caught exception has {:type :sci/error} ex-data, it passes (ex-cause e) to the handler instead, preserving the original exception and its ex-data.
An alternative approach of using binding [*in-try* true] in the .then callback was tried but failed because throws happen in inner .then callbacks (from awaits), not the outer one where the binding is set.
Loop bindings are sequential - each init can see previous bindings. When a binding shadows a macro (like (loop [-> inc x (-> 1)] ...)), the second init (-> 1) should call the function, not expand as the threading macro. Wrapping in let* ensures proper scoping during analysis.
chain-promises as a shared helper?Both transform-expr-with-await (for function calls like (+ (await p1) (await p2))) and transform-coll-with-await (for [(await p1) (await p2)]) need the same chaining logic: walk transformed elements, and when one is a promise, bind it via .then and continue with the resolved value. chain-promises extracts this shared pattern, taking a rebuild-fn that assembles the final form from resolved elements.
normalize-branches return [normalized? branches]?When if or case* has mixed promise/non-promise branches, all branches must return promises so the containing expression can chain .then on the result. normalize-branches wraps non-promise branches in resolve. It returns a boolean alongside the result because checking “did normalization happen?” by comparing before/after is unreliable — if only one branch is a promise, the non-promise branch changes but the promise branch doesn’t, making equality checks confusing. A single reduce pass that tracks has-promise? during traversal is both simpler and correct.
js/Promise.all for parallel execution.^:async fn or ^:async defn.(await-all [p1 p2 p3])