Teaser Candidates: ClojureScript 1.12.145 (^:async / await)
Table of Contents
- 1. Overview
- 2. T1. Serial vs parallel await in let
- 3. T2. Plain function inside ^:async is not async
- 4. T3. await is not a function
- 5. T4. Async test that "passes" by accident
- 6. T5. binding does not survive await
- 7. T6. swap! across await
- 8. T7. Throwing across await with try/catch :default
- 9. T8. Lazy seq holding an await result
- 10. T9. refer-global shadowing
- 11. Selection Summary
- 12. Next Steps
1. Overview
ClojureScript 1.12.145 introduced ^:async and await as first-class
language constructs, bringing native JavaScript async/await semantics into
ClojureScript. This creates a new category of brain teasers around async
semantics that have no direct equivalent in JVM Clojure.
These teaser candidates target experienced Clojure developers who bring JVM-shaped intuitions to ClojureScript's single-threaded async model.
Repository: clojure-brain-teasers-review
2. T1. Serial vs parallel await in let
(defn ^:async fetch-two [] (let [a (await (slow-promise 100)) b (await (slow-promise 100))] [a b]))
Trick: Feels like parallel, runs serial (200ms). Parallel form needs
~js/Promise.all over a JS array of promises, then a single await.
The let desugaring forces sequencing because each binding suspends
the async frame before the next initializer evaluates.
Refutation: If the compiler ever rewrites independent await-bearing
bindings into a Promise.all, this teaser dies. Current release notes don't
suggest that, but worth a compiler-output check before shipping.
Status: SELECTED – primary teaser, implemented in repo.
3. T2. Plain function inside ^:async is not async
(defn ^:async outer [] (let [f (fn [] (await (p)))] (f)))
The release example explicitly notes (fn [] 20) is "not async". So
await inside a non-async inner fn is a compile error (or worse, a
silent bare-identifier reference). Teaser: which? Verify before authoring.
Add ^:async to the inner fn to fix.
Status: Needs compiler verification.
4. T3. await is not a function
(defn ^:async await-all [ps] (map await ps))
await is a special form. (map await ...) either fails to compile
or treats await as a var lookup. Even if it compiled, map is lazy,
so realization order is undefined relative to the async frame.
Fix: (await (js/Promise.all (clj->js ps)))
Status: SELECTED – implemented in repo.
5. T4. Async test that "passes" by accident
(deftest broken (-> (fetch) (.then #(is (= 42 %)))))
No ^:async, no await. Returns a promise the runner never waits on.
is fires after the test reports success. Teaser asks: does this test
pass, fail, or pass-but-lying? Answer: third.
Demonstrates why ^:async deftest matters.
Status: SELECTED – documented in repo source comments.
6. T5. binding does not survive await
(def ^:dynamic *ctx* nil) (defn ^:async run [] (binding [*ctx* :a] (await (p)) *ctx*))
JVM Clojure has this exact gotcha with futures (frame conveyance).
ClojureScript over native JS async will lose the dynamic binding
across the microtask boundary because binding is a try/finally
push-pop and the async desugar splits the body across event-loop ticks.
Expected return: nil, not :a.
Refutation: If the compiler emits binding-conveyance shims for async fns, this is wrong. Not verified against 1.12.145 sources yet – flagged for compiler-output check.
Status: SELECTED – implemented in repo.
7. T6. swap! across await
(def counter (atom 0)) (defn ^:async bump [] (let [v (await (read-remote))] (swap! counter + v)))
JS is single-threaded but the event loop interleaves. Two concurrent
(bump) calls can both read @counter before either swap!
(if you read before await). The example above is actually safe because
the read of counter is inside swap! after await. The teaser is the
version that reads first:
(let [c @counter, v (await (read-remote))] (reset! counter (+ c v))) ;; race
Key insight: Single-threaded-therefore-safe is the wrong intuition once you introduce suspension points.
Status: Strong candidate, not yet implemented.
8. T7. Throwing across await with try/catch :default
(defn ^:async safe [] (try (await (rejecting-promise)) (catch :default e :caught)))
Confirms rejection -> exception in catch. Pair with a version where
try is outside the async fn and the caller does
(.catch (safe) ...) without await – different semantics, different
catch site.
Status: Good secondary candidate.
9. T8. Lazy seq holding an await result
(defn ^:async items [] (map inc (await (fetch-coll))))
Returns a lazy seq whose head is fine but whose realization escapes
the async frame. If fetch-coll resolved to a chunked seq of
promises rather than values, realization at the call site is
synchronous and won't await them.
Status: Niche – depends on specific return-type confusion.
10. T9. refer-global shadowing
(refer-global :only '[Promise]) (require '[my.lib :refer [Promise]])
Which wins? Order-dependent. A brain teaser if the book hasn't covered
refer-global yet (introduced ahead of this release).
Status: Needs more investigation on precedence rules.
11. Selection Summary
| Teaser | Title | Status | Strength |
|---|---|---|---|
| T1 | Serial vs parallel await in let | Implemented | Strong |
| T2 | Non-async inner fn | Needs check | Medium |
| T3 | await is not a function | Implemented | Strong |
| T4 | Test passes by accident | Documented | Strong |
| T5 | binding lost across await | Implemented | Strong |
| T6 | swap! across await race | Candidate | Strong |
| T7 | try/catch across await boundary | Candidate | Medium |
| T8 | Lazy seq escaping async frame | Candidate | Niche |
| T9 | refer-global shadowing | Needs check | Niche |
12. Next Steps
- Verify T2, T5, T9 against ClojureScript 1.12.145 compiler output
- Add shadow-cljs build config to test repo for full compilation
- Measure actual timing for T1 serial vs parallel (CI benchmark)
- Write up final versions of top 3-4 teasers for publication
