Safe ClojureScript Interop Under :advanced Compilation: Externs, cljs-oops, and shadow-cljs Compared
Compare externs files, cljs-oops, and shadow-cljs npm integration for safe ClojureScript interop under :advanced compilation. Includes a validation checklist that catches renaming bugs before they reach production.
24 Nov 2025, 10:36 UTC

The Problem: Property Renaming Breaks Interop
When you compile ClojureScript with :advanced optimizations, the Google Closure Compiler aggressively renames property names it considers internal. A call like (.someMethod js-lib) compiles to something like js-lib.aB() at runtime, while the actual JavaScript library still exports someMethod. The result is a runtime error—usually undefined is not a function—that only appears in production builds. Development builds (:none or :simple) do not rename, so the bug stays hidden until you ship.
The decision is not whether to use :advanced (you should for production), but how to protect the external property names your code depends on. Three mainstream approaches exist: hand-written or generated externs files, the cljs-oops library for string-based access, and shadow-cljs's npm integration with automatic externs inference. Each has distinct trade-offs in maintenance burden, compiler checking, and output size.
Option Comparison
| Approach | Mechanism | Maintenance | Compiler Checks | Output Size Impact | Best For |
|---|---|---|---|---|---|
| Externs files | Declare property names the compiler must not mangle | High for niche libraries; low for popular ones (via CLJSJS) | Full—compiler validates calls against externs | Minimal | Stable, well-known APIs; teams willing to maintain externs |
| cljs-oops | Access properties via string names: (ocall js-lib "someMethod") |
Low—no externs needed | None for the accessed names; macros expand to string lookups | Slightly larger (string literals retained) | Dynamic/external data shapes, quick prototyping, libraries without externs |
| shadow-cljs npm + inference | Consume npm packages directly; compiler infers externs from ^js hints and usage |
Low—automatic for required npm modules | Good—inference warnings catch many missing hints | Minimal | Projects already on shadow-cljs; npm-heavy dependencies |
Trade-offs in Detail
Externs Files: Explicit but Laborious
Externs are the Closure Compiler's native contract. You write a JavaScript-like file listing the external names:
// externs/react.js
var React = {};
React.createElement = function() {};
React.Component = function() {};
React.Component.prototype.setState = function() {};
Then reference it in your build config (:externs ["externs/react.js"]). The compiler will refuse to rename createElement, Component, or setState. For widely used libraries, CLJSJS packages ship pre-made externs. For a private or less common npm package, you must write and maintain the file yourself—every version upgrade may require updates. Missing a single property causes a silent runtime failure.
cljs-oops: String Access Without Externs
cljs-oops provides macros that compile to goog.object.get or bracket notation with string literals:
(ns app.core
(:require [cljs-oops :refer [ocall oget oset]]))
(ocall js-lib "someMethod" arg1 arg2)
;; compiles to: js_lib["someMethod"](arg1, arg2)
Because the property name is a string, the compiler never renames it. The cost is losing the compiler's arity and existence checks for those calls, and a modest increase in output size (the strings remain). It is a pragmatic choice for dynamic APIs (e.g., third-party widgets loaded at runtime) or when you cannot invest in externs.
shadow-cljs: Automated Externs for npm
shadow-cljs treats npm packages as first-class. When you require an npm module, shadow-cljs can generate externs automatically based on the package's TypeScript definitions or its JavaScript source. Combined with ClojureScript's ^js type hints, the compiler infers which properties are external and protects them. Example shadow-cljs.edn snippet:
{:builds {:app {:target :browser
:modules {:main {:init-fn app.core/init}}
:infer-externs :auto
:externs-inference-limit 1000}}}
In your code, hint the imported namespace:
(ns app.core
(:require ["lodash" :as _]))
(defn process [data]
(._chunk ^js _ data 10))
The ^js hint on the _ symbol tells the compiler that property accesses on that object are external. Shadow-cljs then ensures _chunk is not renamed. You still get compiler warnings if you mistype a property that appears in the inferred externs.
Concrete Validation Strategy
No matter which approach you choose, the only reliable validation is a production build that exercises every interop path. Follow these steps in your CI pipeline:
- Build with
:advanced. For shadow-cljs:npx shadow-cljs release app. For cljs.main:clojure -M -m cljs.main -O advanced -c app.core. - Run a smoke test in a headless browser. Use Playwright, Puppeteer, or Karma to load the compiled
main.jsand execute a script that calls every interop function your application uses. Check the console forTypeErrororundefined is not a function. - Enable inference warnings. Set
:infer-externs :auto(shadow-cljs) or:warn-on-infer true(cljs.main). The build should emit zero inference warnings for your interop namespaces. - Inspect the compiled output. Search the generated JavaScript for the exact library property names (e.g.,
someMethod). They should appear un-mangled. Source maps make this easier: open the mapped source in DevTools and verify the property names match the library's documentation.
If any step fails, you have a missing extern, a missing ^js hint, or a cljs-oops call that points to a renamed property. Fix, rebuild, and re-run.
Limitations and Gotchas
- Compiler version drift. The Closure Compiler version bundled with ClojureScript and shadow-cljs changes. Externs inference behavior and renaming heuristics evolve. Pin your toolchain versions in
deps.ednorpackage.jsonand re-run the validation suite after upgrades. - Inference is not exhaustive. Higher-order patterns (e.g., passing a library method as a callback) may not be caught. Add explicit
^jshints on the callback argument or write a small externs fragment for those edge cases. - Mixing mechanisms masks issues. If you use both externs and cljs-oops for the same library, you won't know which one is actually protecting a name. Pick one per library.
ageton plain objects is discouraged. Usegoog.object/getor cljs-oops for dynamic keys;agetis specified for arrays and may produce warnings or unexpected behavior on objects in future compiler versions.
Recommended Default Stack
For a new project in 2026:
- Use shadow-cljs with
:infer-externs :autoas the baseline. - Add
^jshints on every namespace that holds an imported npm module. - Reserve cljs-oops for truly dynamic access (e.g., user-provided plugin objects, analytics APIs loaded from a CDN).
- Write hand-crafted externs only for non-npm globals (e.g., a legacy script tag that exposes
window.MyLegacyLib). - Enforce the CI validation steps above on every merge to main.
This combination minimizes manual extern maintenance, retains compiler checking for the majority of calls, and gives you a clear escape hatch for the few cases where static inference falls short.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.