sci

ADR 0002: Async/Await for ClojureScript

Status Date Related
Implemented 2026-02-06 Inspired by core.async ioc_macros

Context

Users want async/await syntax for SCI in ClojureScript to simplify working with Promises.

Problem

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.

Inspiration: core.async

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:

For our implementation, we use a simpler approach: direct code transformation without full SSA, targeting Promise .then chains.

Solution: Macro-based Code Transformation

Transform async function bodies at analysis time into Promise .then chains.

Syntax

(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))))

Implementation Scope

Supported features:

Supported special forms:

Macros:

Implementation

Performance: Helper Functions

For performance, the transformation emits calls to helper functions in sci.impl.async-await rather than raw JS interop. These helpers:

  1. Are native CLJS functions that compile to efficient JS
  2. Serve as reliable markers for promise-form? detection
  3. Avoid SCI’s interpreted interop overhead
;; 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).

Key Insight: Promise Form Detection

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:

  1. Recursively transform all subforms first (this expands macros)
  2. Check if any transformed subform is a promise-form?
  3. If so, chain with helper functions to sequence the promise

This avoids needing a separate “contains await” check and handles macro expansion naturally.

Transformation Algorithm

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:

For 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:

For 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)))

Analyzer Integration

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)

Files

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

Testing

Run with: script/test/node

Test cases cover:

Design Decisions

Why helper functions instead of raw interop?

The transformation emits (sci.impl.async-await/then ...) instead of (.then ...) for two reasons:

  1. 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.

  2. 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.

Why promise-form? detection instead of contains-await?

Initially we tried tracking whether expressions contain await calls. This had issues:

  1. Macros might hide await calls
  2. Required two passes (detect then transform)

Using promise-form? on transformed results:

  1. Macros are expanded first, revealing awaits
  2. Single pass transformation
  3. More robust: if something produces a promise, chain it

Why wrap await args in sci.impl.async-await/resolve?

This ensures non-promise values work correctly:

(await 42)  ;; Works - 42 is wrapped in Promise.resolve

Why recursive functions for loop/recur?

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.

Why metadata markers instead of a wrapper macro?

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:

  1. No macro expansion needed later
  2. Form structure stays unchanged
  3. More idiomatic Clojure

Why handle recur in transform-async-body instead of a pre-pass?

Initially recur was replaced with recursive function calls in a separate replace-recur pass before transformation. This had two problems:

  1. Macros expanding to recur: A macro like (defmacro my-recur [& args] (cons 'recur args)) wouldn’t be expanded yet during the pre-pass, so the recur would be missed.
  2. Quoted recur: The pre-pass had to be careful not to descend into (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.

Why 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.

Why wrap loop inits in let*?

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.

Why 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.

Why does 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.

Limitations

Future Extensions