Status: Accepted as direction, nothing implemented. Depends on the immutable branch (persistent data structures with protocol-dispatched core) being merged first.
Names, to avoid three-way confusion: the dialect value in
:squint/dialect ns metadata is :cljs (against :squint, the
default). The reader conditional feature key is :squint/cljs, since
bare :cljs already means real ClojureScript there. Prose says cljs
mode or cljs dialect.
ADR 0004 rejected real keywords as the default runtime representation.
The costs were ambient and the killer constraint was backward
compatibility: keywords had to keep working against string-keyed data,
which produced the altKey double lookup, the loose = debates and the
js/Map miss family. The benefit, wire-boundary type fidelity, turned out
to be solvable in userland at one encode/decode choke point (0004,
Resolution section).
The immutable branch changes the base assumptions. Once persistent collections land in core and core functions dispatch through protocols, core is representation-agnostic. A keyword type then no longer needs core rewritten, it needs protocol implementations, a small set of overridden vars and a compiler audit (see Core function reuse).
This ADR proposes an opt-in compile mode instead of a new default. The mode owes no compatibility to string-keyed squint data, which removes the constraint that generated most of 0004's pain.
A dialect resolved per namespace. :squint/dialect ns metadata pins a
namespace and travels with the source, so a library compiled from a git
dep or a jar keeps its dialect inside a consumer running the other one.
Ns metadata is the only channel that survives every packaging: squint
compiles dependencies from source directories and jars, where
out-of-band config like the library's own squint.edn is not present.
Initially ns pinning is the only mechanism: every namespace without
metadata is :squint, project and dependency alike. Explicit per file,
no inheritance rules to misread, and everything published before the
mode keeps the semantics it was written against. Inheriting the
consumer's mode would hand old libraries new semantics silently, the
ambient surprise 0004 rejected, moved to the dependency boundary.
Possible later conveniences, in rough order:
:dialect,
so an app does not annotate its own files. Dependency resolution
already knows which source root a file came from.:squint/dialect :inherit for genuinely dual-dialect libraries,
following the consumer on purpose instead of by omission.Dialects therefore mix inside one program. The protocol-dispatched runtime is shared, but a keyword literal is a string in one dialect and a Keyword in the other, so lookups across the boundary miss. Data crossing between dialects is the user's problem.
In cljs mode:
case keeps compiling to switch, since
interning makes === correct. Symbols intern globally the same way,
decided here rather than left open, because case on symbols
otherwise needs separate behavior.#js literals stay native JS arrays and objects, the literal-level
escape hatch from persistent collections.(= :a "a") is false. No name-equivalent equality anywhere.(:foo m) and (:foo m nf).
The compiler currently infers keyword callability from the string
tag, cljs mode needs a distinct Keyword routing path for it.(str :foo) is ":foo" and toString matches, CLJS fidelity over
current squint's "foo". String sinks (DOM attributes, hiccup) take
(name k), as in CLJS. Anything else silently weakens the claim that
the mode shares CLJS semantics.=== and case work across copies.
Separate realms and workers share neither the weak table nor object
identity, cross-realm data goes through the wire encoding as always.
Weak references keep the table from pinning keywords, as in the POC's
weak interning. Registry keys carry a contract version: once protocol
slots use Symbol.for they are an ABI shared by runtime copies from
different squint versions, and the key must change when the slot
contract does.Most of core is reused unchanged through protocol dispatch. The public
var overrides in the overlay module (working name squint.cljs-mode)
are a short list:
keyword, keyword?, name, namespace, symbol, symbol?,
find-keywordpr-str path for Keyword and Symbol)clj->js and js->clj, the sanctioned deep conversionThe var list understates the compiler audit. Beyond it, the mode
touches: literal emission for keywords, symbols, maps, vectors and
sets, keyword-as-function inference (string tag today, Keyword routing
needed), hashing and equality protocol implementations for the new
types, case constant emission, quoted forms, destructuring output,
and JSX/hiccup tag positions where keywords flow into strings.
Remapping mechanism: a second edn resource next to core.edn listing the
overridden munged names. In cljs mode the compiler prefixes those vars
with the overlay import and everything else stays squint_core. Static
imports keep tree-shaking intact. Note core.edn is inlined at
macroexpand by edn-resource, the second table gets the same treatment
and the same rebuild footgun.
Pure CLJS get ignores JS objects entirely. This mode keeps squint's
string access and drops only the keyword side:
(get js-obj "foo") reads the property, unlike CLJS. assoc,
contains?, get-in, select-keys and update with string keys
work through the same OBJECT_TYPE branch. {:strs [a b]}
destructuring is the interop idiom.(get js-obj :foo) is nil, like CLJS. Keywords never coerce to
property names. Crossing is explicit: (name k), js->clj,
clj->js. A keyword miss doubles as a signal that the value is JS
data, not clj data.(get {:a 1} "a") is nil.(keys js-obj) returns
strings and (get o (first (keys o))) works, fully symmetric.Why this does not reopen the altKey mess: in 0004 one representation (objects) served both worlds, so every lookup was ambiguous and needed double probing. Here nothing coerces at all, each key type addresses the collection kinds it natively fits.
Documented edges, not solved ones:
(.get m "a") does not find :a.
Real CLJS behaves the same.(:foo js-obj) works today because
keywords are strings, and under the mode it silently returns nil
instead of erroring. The biggest porting trap in the design.:squint everywhere.=== in JS
libraries) return as documented mode semantics. Acceptable when the
app opted in, deadly when ambient. That is the line between this
proposal and the rejected default.Reader conditionals: the cljs mode feature set is
#{:squint/cljs :cljs}, bare :squint deliberately absent, :default
matching as fallback behavior like everywhere else. Default squint
already falls back to :cljs, so existing #?(:squint ... :cljs ...)
code does the right thing in both dialects with no duplication: the
:cljs branch serves real CLJS and cljs mode, which share semantics by
construction. :squint/cljs is the rare escape key for the places
where the mode diverges from real CLJS, such as string access on JS
objects, or :cljs branches leaning on hosts squint lacks (goog,
cljs.core internals).
A library that requires one dialect pins its namespaces and keeps it in
every consumer. A library with no metadata stays :squint, which is
what its code assumed when it was written. Dialect-neutral source, one
source that compiles correctly either way via those conditionals, is
the accurate term during the initial phase: without :inherit it
always compiles as :squint, even inside a cljs mode app, and its data
enters through the ordinary cross-dialect boundary. It only becomes a
true dual-dialect library once :squint/dialect :inherit exists.
Supersedes the "revisit if" framing for the opt-in case. 0004 remains the measured record: the performance numbers, the boxed String method trap and the typeof gate trap all apply to any code path where a Keyword object crosses into JS unlowered, mode or not. What changes is consent: cljs mode makes those documented dialect semantics instead of ambient surprises.
Can you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |