ClojureWasm (cljw) is a from-scratch Clojure runtime written in Zig. Its
north star is behavioural equivalence with JVM Clojure on the
user-observable surface (F-011): the same input produces the same value,
including which inputs are accepted versus rejected. The internals are free
to be Zig-native rather than a JVM port.
This page is the single-sheet answer to "how does cljw differ from Clojure?" — in the spirit of ClojureScript's "Differences from Clojure". It has two parts:
- Part 1 — Intentional divergences:
behaviours that differ by design and will not be "fixed". Each is
anchored to a project invariant (an
F-NNNfact or an ADR) and is locked by a regression test so it cannot drift silently. These are theAD-NNNrows of the machine-readable ledger.dev/accepted_divergences.yaml. - Part 2 — Not yet implemented: surface that Clojure has and cljw does not yet (or, for a few JVM-only corners, ever) carry. These are tracked work items, not silent gaps.
If a behaviour is not on this page and differs from Clojure, treat it as a bug, not a feature.
The single root cause behind most of Part 1 is no JVM: cljw has no
java.lang.Class hierarchy (a TypeDescriptor instead, ADR-0059), no
reproducible identity-hash address, and a single Zig-native numeric model
(F-005). Wherever Clojure's observable surface leaks one of those JVM
facts — a class FQCN, a 0xADDR, an f32, a JVM exception class — cljw
substitutes its own honest surface and records the divergence here.
By-design; the "why" below is the short form, the full rationale is in the
linked ledger entry. Examples are cljw => … / clj => …, clj-verified.
Opaque / identity-bearing values cannot be reproduced because clj's
#object[Class 0xADDR …] form embeds a JVM class FQCN and a
non-reproducible identity hash. cljw prints a stable, honest form instead.
| Behaviour | Clojure (JVM) | ClojureWasm | AD |
|---|---|---|---|
| Set / non-sorted-map print order | hash-order (version-dependent) | deterministic insert-derived order | AD-001 |
| Opaque references (atom / fn-less / …) | #object[clojure.lang.Atom 0x… {…}] |
#<atom> |
AD-002 |
A namespace value (*ns*, :ns meta) |
#object[clojure.lang.Namespace 0x… "user"] |
#object[Namespace "user"] |
AD-010, AD-021 |
A PersistentQueue |
opaque #object[…] (no print-method) |
readable #queue (1 2 3) + #queue reader |
AD-012 |
A host object (java.util.Random …) |
#object[java.util.Random 0x… …] |
#<java.util.Random> |
AD-020 |
A callable (fn / defmulti / proto-fn) |
#object[user$boom__N 0xHASH "…@…"] |
#<user/boom> (#<fn> if unnamed) |
AD-025 |
(str *ns*) / (ns-name …) and every value's behaviour stay clj-faithful;
only the pr/prn identity rendering diverges. str and pr of a callable
render identically (matching clj).
| Behaviour | Clojure (JVM) | ClojureWasm | AD |
|---|---|---|---|
Long overflow past i64 |
+ / * throw ArithmeticException (only +' / *' promote) |
auto-promotes to BigInt | AD-008 |
(float x) |
yields an f32 | yields an f64 (no f32 representation) | AD-004 |
| Subnormal double shortest-render | 4.9E-324 |
5.0E-324 (same f64 bit pattern) |
AD-005 |
(biginteger 5) |
5 of class java.math.BigInteger |
5N of class BigInt (one big-int type) |
AD-016 |
(* Long/MAX_VALUE 2) => 18446744073709551614N (cljw) vs a throw (clj) is
the one accept/reject difference; the rest are cosmetic or rare-edge. cljw
collapses clj's BigInt/BigInteger into one .big_int, so every op on a
biginteger already returns BigInt in both runtimes ((class (+ (biginteger 5) 1)) => BigInt both).
cljw carries a TypeDescriptor, not a java.lang.Class; its collections are
native types, not java.util.* implementors; and it has one value-hash, not
the JVM's hashCode/hasheq split.
| Behaviour | Clojure (JVM) | ClojureWasm | AD |
|---|---|---|---|
(class x) / (type x) |
java.lang.Long (FQCN) |
Long (simple name) |
AD-003 |
| Error rendering | ArithmeticException … (JVM exception class) |
[arithmetic_error] … (catalog Kind); same accept/reject |
AD-007 |
| Stack trace frames | includes clojure.core machinery |
user frames only (stdlib + host elided uniformly) | AD-024 |
clojure.stacktrace per-frame printing |
Class.method (file:line) frames |
[no stack trace available] marker (cause-chain + message work) |
AD-029 |
hash / .hashCode values |
JVM/Murmur3 values | cljw-native values (intra-cljw consistent) | AD-009 |
APersistentMap/mapHash vs /mapHasheq |
distinct (additive hashCode vs murmur) |
both = the single (hash m) content hash |
AD-028 |
ns-interns/ns-publics of clojure.core |
includes reduce, +, … |
omits the rt-referred primitives (ns-map includes them) |
AD-011 |
(class (object-array 0)) + typed arrays |
[Ljava.lang.Object;, aset type-checks |
array; type-erased []Value, ^"[B" hints advisory |
AD-019 |
Unresolved clojure.lang.* / .asm.* ref |
resolves to the JVM class | namespace LOADS; the ref errors only if evaluated (call-time) | AD-022 |
(extend-protocol P java.util.Map …) |
covers all clj maps (a map IS a java.util.Map) |
LOAD-ONLY no-op; a map receiver falls to Object |
AD-023 |
java.util.Map methods under a clojure.lang.* deftype section |
dispatch (the interfaces extend the java ones) | accepted-and-dropped; a later (.iterator x) is method-not-found |
AD-027 |
(class 5) => Long, (quot 10 0) rejects in both (only the message format
differs), and (= (hash "abc") (.hashCode "abc")) holds within cljw — the
HAMT key contract that actually matters. Integers happen to match clj's hash
value; strings/keywords differ. Java arrays, clojure.lang.* refs, and
java.util.* protocol targets all resolve to an explicit error or a
declared no-op, never a silent success.
cljw stores every deftype field as one 8-byte NaN-boxed Value, so the JVM's primitive-slot machinery has nothing to constrain.
| Behaviour | Clojure (JVM) | ClojureWasm | AD |
|---|---|---|---|
^long / ^"[B" on a mutable field |
primitive slot; set! of a non-prim throws |
hint parsed-and-ignored (advisory) | AD-017 |
:volatile-mutable vs :unsynchronized-mutable |
differ in cross-thread visibility | unified to one in-place write (single-thread; revisit Phase 15+) | AD-018 |
| Behaviour | Clojure (JVM) | ClojureWasm | AD |
|---|---|---|---|
| STM conflict resolution | barge (older txn preempts a younger) |
retry-only; identical committed result | AD-013 |
An ESCAPED with-local-vars var, deref'd late |
#object[clojure.lang.Var$Unbound 0x…] |
nil (no Unbound sentinel; memory-safe) |
AD-015 |
STM's committed state is identical (4 threads × 100 (dosync (alter c inc))
=> 400 in both); only contention scheduling — unobservable in the result —
differs. Locking and with-local-vars are clj-faithful inside their normal
use; only the literal-as-lock and escaped-var anti-patterns diverge.
cljw stores a string as UTF-8 and every index into it counts Unicode code
points. The JVM stores UTF-16 and clj indexes by code unit, so a character
outside the Basic Multilingual Plane (emoji, rare CJK, most historic scripts)
is one position in cljw and two (a surrogate pair) in clj. BMP text, including
multibyte characters such as é or 世, indexes the same in both.
| Expression | Clojure 1.12 (JVM) | ClojureWasm | AD |
|---|---|---|---|
(count "🌍") |
2 |
1 |
AD-076 |
(count "ab🌍") |
4 |
3 |
AD-076 |
(.length "🌍") |
2 |
1 |
AD-076 |
(subs "a🌍b" 1 2) |
a lone high surrogate | "🌍" |
AD-076 |
(vec (map int "🌍")) |
[55356 57101] |
[127757] |
AD-076 |
(str (first "🌍")) |
a lone high surrogate | "🌍" |
AD-076 |
(.indexOf "a🌍b" "b") |
3 |
2 |
AD-076 |
cljw column measured on v1.14.11; clj column from issue #1.
Both surfaces use code points, on purpose. The language surface (count,
subs, nth, get, seq) and the Java interop names (.length, .charAt,
.indexOf, .substring) share one index space, so (= (count s) (.length s))
always holds and an index from .indexOf is safe to pass to subs. The
interop methods are therefore not a faithful java.lang.String#length: they
keep the Java names for source compatibility and answer in code points.
Porting JVM code. Code that only takes indices from one string operation
and feeds them to another (.indexOf then subs, count then a loop over
nth) keeps working unchanged, and stops producing half surrogate pairs.
Look again at code that:
- compares a length or offset with one computed outside cljw: a UTF-16
offset from a JavaScript or Java peer, an LSP
characterposition, a protocol field counting UTF-16 units. Convert at the boundary rather than inside the program. - tests
(Character/isHighSurrogate c)/isLowSurrogate, or reassembles pairs withCharacter/toCodePoint: in cljw a char from a string is already a whole code point, so those branches never fire. - sizes a buffer from
.lengthexpecting bytes. Neither runtime gives bytes there; use(count (.getBytes s "UTF-8")), which agrees in both.
| Behaviour | Clojure (JVM) | ClojureWasm | AD |
|---|---|---|---|
(read-string "#=(+ 1 2)") |
3 (*read-eval* true → the form is run) |
error No reader function for tag = (eval-free) |
AD-026 |
cljw's read-string is the same eval-free path as clojure.edn/read-string:
reading data never executes code (secure-by-default; the JVM read-eval footgun
is removed). Evaluation is reached only via an explicit eval on read data.
Gaps relative to Clojure, tracked as work items — a missing Tier A/B/C form raises an explicit error rather than quietly mis-behaving.
The concurrency surface is complete (future / promise / delay, full STM
dosync / alter / commute / ensure / ref-set, atom with CAS, agent
with error modes — agent-error / restart-agent / set-error-handler! /
agent-errors / clear-agent-errors — reference watches — add-watch /
remove-watch fire uniformly across atoms, agents, refs and vars —
await / await-for, shutdown-agents, locking, volatile, real threads,
Thread/sleep). The lower-frequency tail:
- validators are wired on atoms and agents (
set-validator!/get-validator, and the(atom v :validator f)/(agent v :validator f)ctor option);refandvarvalidators are not yet wired (set-validator!on a ref/var errors "expected atom or agent") - the
refconstructor option map —(ref v :validator f)/:min-history/:max-history— is not accepted (clj'srefdoes); pair it with the validator-on-refs gap above
Because cljw is no-JVM, the following Clojure forms that exist only to bridge to the JVM are not part of the runtime (Tier C/D):
gen-class,gen-interface,compile(AOT to.classfiles)- deep
proxy(subclassing arbitrary Java classes),bean - reflection over, and
importof, arbitrary Java classes
cljw provides its own host-class surface (a curated set, see
data/compat_tiers.yaml) rather than open JVM interop.
Note that Java arrays are implemented (type-erased — see AD-019), as is
with-local-vars (AD-015); they are no longer in this list.
A divergence listed in Part 1 is designed: it derives from a project invariant and is pinned by a regression test, so it reads as a deliberate choice rather than a defect. A gap listed in Part 2 was planned but never built — it sat on the roadmap when the project stopped, or, for the JVM-only corners, was intentionally excluded. Anything not on this page that differs from Clojure is a bug that was never found.
Both parts are a map of the territory as upstream left it, not a list of things about to change: this fork inherited the divergences rather than choosing them. Part 1 marks behaviour not to "correct" without first understanding the invariant behind it; Part 2 marks what is genuinely missing, with what was intended.
For the authoritative, machine-readable form of Part 1, see
.dev/accepted_divergences.yaml; each
AD-NNN there carries its derives_from invariant, its clj-verified
example, and its pin test.