| description | Reference for a model writing, reviewing, or cleaning up modern JavaScript - language syntax, modules, async, errors, DOM and platform APIs, Node.js, security, testing, linting, and JSDoc typing |
|---|
This file equips a model to write, extend, and clean up modern JavaScript (ES2022+). Read the non-negotiable rules and the closing restatement first; they bind every edit. Sections run from most to least frequently needed during cleanup and are consulted one at a time, so the file's length does not collide with the constraint budget. Every rule is chosen to be mechanically detectable with a concrete bad -> good correction. Rules that change runtime behavior are marked as suggestions, not silent auto-fixes.
Follow these on every change; they are restated at the end.
- Use
constby default,letwhen reassigned, nevervar; avaris always a cleanup target. - Use
===/!==, never==/!=; the sole sanctioned exception isx == nullto test for null-or-undefined. - Throw
Errorobjects (or subclasses), never strings or plain values; a thrown string has no stack trace. - Never leave a floating promise;
awaitit, chain.catch(), or mark deliberate fire-and-forget withvoid fn().catch(...). - Never assign untrusted data to
innerHTML,eval, ornew Function; usetextContentor sanitize with DOMPurify. (safety) - Use ESM (
import/export) for new code, with explicit file extensions and thenode:prefix for built-ins.
const/let are block-scoped; var is function-scoped, hoisted with an undefined initializer, and produces loop-closure bugs. Rewrite every var to let, then downgrade to const where never reassigned. See MDN: let.
// bad
var total = 0;
for (var i = 0; i < items.length; i++) { /* i leaks; closures share one i */ }
// good
let total = 0;
for (let i = 0; i < items.length; i++) { /* fresh binding per iteration */ }== runs the abstract equality algorithm and produces traps: 0 == '', [] == ![], null == undefined all true; NaN == NaN false. Use ===/!==. The one sanctioned exception is x == null, which is true for exactly null and undefined (matches ESLint eqeqeq smart mode).
// bad
if (x == 5) { }
// good
if (x === 5) { }
// sanctioned exception - tests null OR undefined
if (value == null) return fallback;For NaN-aware or signed-zero comparison use Object.is or Number.isNaN, never x === NaN (always false).
Make conversions explicit; '5' + 1 is '51' but '5' - 1 is 4. The falsy values are exactly false, 0, -0, 0n, '', null, undefined, NaN - everything else, including [] and {}, is truthy. See MDN: Type coercion.
// bad - '5' + count concatenates; if (arr) never detects empty
const total = '5' + count;
if (list) process(list); // [] is truthy
// good
const total = Number('5') + count;
if (list.length > 0) process(list);|| returns the right side for any falsy left side, discarding valid 0/''/false. ?? triggers only on null/undefined. See MDN: Nullish coalescing.
// bad - a volume of 0 becomes 1
const volume = settings.volume || 1;
// good
const volume = settings.volume ?? 1;?? cannot be mixed with ||/&& without parentheses (SyntaxError): write (a ?? b) || c.
var x = 1->const x = 1/let x = 1- nevervar.x == 5->x === 5- strict equality.result === NaN->Number.isNaN(result)- NaN is never===itself.+userInput/'5' + n->Number(userInput)- explicit coercion.if (count) render()->if (count != null) render()- avoid the0falsy trap. *config.port || 8080->config.port ?? 8080- preserve0/''/false. *
Collapse existing existence-guard chains with ?., and default-assignment idioms with ??=/||=/&&=. See MDN: Optional chaining and V8: Logical assignment.
// bad
const city = user && user.address && user.address.city;
if (opts.timeout == null) opts.timeout = 3000;
// good
const city = user?.address?.city;
opts.timeout ??= 3000;Only collapse chains that already have guards; do not rewrite a.b.c to a?.b?.c blindly - that masks real bugs.
Use #name for true language-enforced privacy and field declarations instead of constructor assignment plus _name convention. See V8: Class fields. This is behavior-changing; surface as a suggestion.
// bad
class Counter { constructor() { this._count = 0; } _tick() { this._count++; } }
// good
class Counter { #count = 0; #tick() { this.#count++; } get value() { return this.#count; } }- Use
structuredClone(x)overJSON.parse(JSON.stringify(x)), which dropsundefined/functions, manglesDate/Map/Set, and throws on cycles. (Cannot clone functions or DOM nodes; does not preserve class prototypes.) - Use
Object.hasOwn(obj, k)overobj.hasOwnProperty(k). - Use
arr.at(-1)overarr[arr.length - 1],arr.findLast(fn)over[...arr].reverse().find(fn),nested.flat()over[].concat(...nested). - Use non-mutating
toSorted/toReversed/with(ES2023) when the original must not change:data.sort()mutates in place. - Use
for...ofoverfor...inon arrays (for...inyields string keys and inherited props). - Use rest parameters
(...args)over the array-likeargumentsobject.
a && a.b && a.b.c->a?.b?.c- collapse existing guard chains only.cache[k] = cache[k] || f()->cache[k] ||= f()- short-circuits assignment.JSON.parse(JSON.stringify(x))->structuredClone(x)- handles cycles, Map/Set/Date. *obj.hasOwnProperty(k)->Object.hasOwn(obj, k).arr[arr.length - 1]->arr.at(-1).data.sort(cmp)(unintended mutation) ->data.toSorted(cmp). *Array.prototype.slice.call(arguments)->(...args)rest parameters.for (const i in arr)->for (const item of arr). *
Use import/export, not require/module.exports, for new code. ESM enables static analysis, tree-shaking, and top-level await. A file loaded as ESM has no require, module.exports, __dirname, or __filename. See MDN: JavaScript modules.
- ESM relative imports must include the file extension:
import './math.js', not'./math'(throwsERR_MODULE_NOT_FOUND). - No implicit directory imports: point at
'./util/index.js'explicitly. - Prefix built-ins with
node::import fs from 'node:fs'- unambiguous, cannot be shadowed by an npm package.
- Declare
"type": "module"so.jsis ESM. Use.cjs/.mjsto force a format. - Prefer the
"exports"field over bare"main"; it defines explicit entry points and encapsulates internals. - Order conditional export keys most-specific to least, ending with
"default"; the first match wins, so a misplaced"default"shadows the rest.
- Prefer named exports over a large
export default { ... }object, which forces bundlers to keep the whole object. See webpack: Tree Shaking. - Avoid wildcard barrel files (
export * from './x') in application code; they force the bundler to traverse the entire aggregation chain and defeat tree-shaking. Use named re-exports or direct imports. - Declare
"sideEffects": false(or list exceptions like["*.css"]) to let bundlers drop unused pure modules. - Detect and remove circular dependencies with
madge --circularor ESLintimport/no-cycle.
const { add } = require('./math.js')->import { add } from './math.js'.import { add } from './math'->import { add } from './math.js'- ESM needs the extension.import fs from 'fs'->import fs from 'node:fs'.export default { formatDate, formatCurrency }-> namedexport functiondeclarations.export * from './Button.js'->export { Button } from './Button.js'or direct import.import _ from 'lodash'->import debounce from 'lodash-es/debounce'- subpath import.{ "main": "index.js" }->{ "type": "module", "exports": { ".": { "import": "./index.mjs", "require": "./index.cjs" } } }.
- Never wrap an existing promise in
new Promise(...); return it directly. Reserve the constructor for adapting callback/event APIs, and never make the executorasync(its thrown error is lost and the outer promise hangs). - Do not mix
.then()andawaitin the same function. - Use
Promise.withResolvers()instead of capturingresolve/rejectin outer variables.
awaitinsideforEachdoes nothing -forEachignores the returned promise. Usefor...of(sequential) orPromise.all(items.map(...))(parallel).- Run independent awaits in parallel with
Promise.all, not sequentially. - Drop
return await poutsidetry(redundant tick), but keep it insidetry/catchso thecatchcan observe the rejection. - No
asyncarray comparators/predicates (they return a truthy promise, breakingsort/filter). - Bound unbounded concurrency: batch or use
p-limitinstead ofPromise.all(hugeArray.map(...)).
// bad
items.forEach(async (item) => { await processItem(item); });
const a = await fetchA(); const b = await fetchB(); // independent
// good
for (const item of items) await processItem(item); // sequential
const [a, b] = await Promise.all([fetchA(), fetchB()]); // parallel| Combinator | Fulfills when | Use for |
|---|---|---|
Promise.all |
all fulfill (rejects on first failure) | dependent tasks; bail on first error |
Promise.allSettled |
all settle (never rejects) | independent tasks; need every outcome |
Promise.race |
first settles (fulfill or reject) | timeouts where a rejection should end it |
Promise.any |
first fulfillment | first success wins; tolerate failures |
Use allSettled when partial failure is acceptable (all discards good results on the first rejection). Use any for "first success", not race (a fast rejection wins a race).
- Replace manual
setTimeout+abort()withAbortSignal.timeout(ms); compose reasons withAbortSignal.any([...]). - Create a fresh
AbortControllerper request - an aborted signal is permanently aborted. - Distinguish
AbortError(user cancel) fromTimeoutError; do not surface a user abort as a real error. - Use
queueMicrotask(fn)to defer to the end of the current turn; usesetTimeout(fn, 0)only when you deliberately want to yield to rendering.
- Mark intentional fire-and-forget with
void fn().catch(...); never leave a bare floating call (an unhandled rejection crashes modern Node). - Never swallow with empty
.catch(() => {}); log at minimum. - Guard async writes to shared state with a version counter or
AbortControllerto avoid last-response-wins races.
arr.forEach(async x => { await f(x) })->for (const x of arr) await f(x)orawait Promise.all(arr.map(f)).new Promise((res, rej) => p.then(res, rej))->return p.new Promise(async (res) => {...})-> a plainasync function.doThing()(floating) ->void doThing().catch(logErr).p.catch(() => {})->p.catch(err => logger.warn(err)).Promise.all(...)when partial failure is OK ->Promise.allSettled(...). *Promise.race(...)for first success ->Promise.any(...). *setTimeout(() => c.abort(), ms)+ fetch ->fetch(url, { signal: AbortSignal.timeout(ms) }).
- Throw
Errorobjects, never strings (onlyErrorinstances carry a stack). ESLintno-throw-literal. - Use custom
Errorsubclasses for discriminable errors (err instanceof DuplicateError), and setthis.namein the constructor. - Preserve context when rethrowing with
Error.cause:throw new Error("failed", { cause: e }). Subclasses must forwardoptionstosuper(message, options)orcauseis dropped. - Use
AggregateErrorfor multiple simultaneous failures instead of throwing only the first. - Put cleanup in
finally, never duplicated across success and catch paths; neverreturn/throwfromfinally(ESLintno-unsafe-finally). - Do not assume the caught value is an
Error: guard withe instanceof Error ? e.message : String(e).
// bad
throw "user not found";
catch (e) { throw new Error("save failed"); } // original error dropped
// good
throw new Error("user not found");
catch (e) { throw new Error("save failed", { cause: e }); }throw "not found"->throw new Error("not found").err.message.includes("dup")branching ->err instanceof DuplicateError.- subclass with no
this.name-> assignthis.name = "MyError". catch (e) { throw new Error("failed") }->throw new Error("failed", { cause: e }).- subclass
super(message)->super(message, options). throw errors[0]->throw new AggregateError(errors, "...").catch (e) { log(e.message) }->e instanceof Error ? e.message : String(e).
- Prefer
querySelector/querySelectorAllovergetElementById/getElementsBy*; the latter return liveHTMLCollections that mutate mid-iteration.querySelectorAllreturns a staticNodeList- spread it ([...nodes]) before using array methods. - Use
textContentoverinnerHTMLfor plain text (XSS). (safety) - Manage classes with
classList.add/remove/toggle, notclassNamestring surgery. - Use
element.dataset.userIdovergetAttribute('data-user-id'). - Use
e.target.closest(selector)over manualparentNodewalks. - Use
addEventListener, never inlineon*attributes orel.onclick =(single-handler, CSP-blocked). - Prefer event delegation (one listener on a stable parent +
closest()) over one listener per item. - Pass
{ passive: true }forscroll/touch/wheellisteners to avoid scroll jank. - Pass
{ signal }from anAbortControllerfor grouped listener teardown, and{ once: true }for one-shot listeners.
getElementById('x')->querySelector('#x').qsa('.x').map(...)->[...qsa('.x')].map(...).el.innerHTML = userText->el.textContent = userText. (safety)el.className += ' active'->el.classList.add('active').el.getAttribute('data-user-id')->el.dataset.userId.<button onclick="...">/el.onclick = fn->el.addEventListener('click', fn).- paired
add/removeEventListenerbookkeeping ->{ signal }+controller.abort().
- Replace
scroll+getBoundingClientRect()visibility checks withIntersectionObserver(lazy load, infinite scroll, impressions);unobserve()once handled. - Replace
window.resize+ manual measurement withResizeObserver; defer DOM writes torequestAnimationFrameto avoid the "ResizeObserver loop" error. - Replace
setIntervalDOM polling withMutationObserver. - Use
requestAnimationFrameoversetTimeout/setIntervalfor animation (vsync-aligned, auto-pauses in background tabs). UserequestIdleCallbackfor non-urgent background work.
- scroll +
getBoundingClientRect()->IntersectionObserver(+unobserve). window.resize+ manual measure ->ResizeObserver.setIntervalDOM polling ->MutationObserver.setInterval(anim, 16)->requestAnimationFrame(step).
- Prefer
fetchoverXMLHttpRequest. fetchdoes NOT reject on 4xx/5xx - it only rejects on network failure. Checkresponse.okand throw yourself before parsing. This is the single most common fetch bug.- Attach a timeout with
{ signal: AbortSignal.timeout(ms) }; untimed fetches leak and cause races. - Bodies are single-use
ReadableStreams; callres.clone()before the first read if two consumers need it.
// bad
const res = await fetch(url);
const data = await res.json(); // parses error pages as success
// good
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`);
const data = await res.json();new XMLHttpRequest()->fetch().await fetch(u); await res.json()with no check -> checkif (!res.ok) throw ...first.- untimed
fetch(url)->fetch(url, { signal: AbortSignal.timeout(ms) }). - reading a body twice ->
res.clone()before the first read.
- Never assign untrusted data to
innerHTML/outerHTML/document.write/insertAdjacentHTML- these are DOM XSS sinks. UsetextContentor safe DOM construction. (safety) - If HTML is genuinely required, sanitize with a maintained library (
DOMPurify.sanitize(html)), never a hand-rolled tag stripper. (safety) - Never use
evalornew Functionwith dynamic data, and never pass a string tosetTimeout/setInterval(implicit eval). UseJSON.parsefor data and an allowlist object for dynamic dispatch. (safety) - Never store secrets or auth tokens in
localStorage(readable by any script via XSS); useHttpOnlycookies. (safety) - Deploy a Content Security Policy without
unsafe-inline/unsafe-evalas defense in depth.
container.innerHTML = apiResponse.html->container.textContent = ...orDOMPurify.sanitize(...). (safety)eval('(' + jsonText + ')')->JSON.parse(jsonText). (safety)new Function('return ' + expr)-> allowlisted dispatch object. (safety)setTimeout('doThing()', 100)->setTimeout(() => doThing(), 100).localStorage.setItem('authToken', t)->HttpOnlycookie for tokens. (safety)
- Prefix all built-ins with
node:(node:fs/promises,node:path,node:crypto). - Prefer
node:fs/promiseswithawaitover*Synccalls, which block the event loop (reserve sync for startup/CLI scripts). - Build paths with
path.join/path.resolve, never string concatenation (breaks cross-platform). - In
child_process, usespawn/execFilewith an args array, neverexecwith interpolated user input (shell command-injection RCE). Passing an args array with{ shell: true }is deprecated (DEP0190). (safety) - Use
node:crypto(randomUUID,randomBytes,randomInt) for tokens/ids/salts, neverMath.random()(predictable). (safety) - In ESM, replace
__dirname/__filenamewithimport.meta.dirname/import.meta.filename(Node 20.11+), or derive fromimport.meta.urlviafileURLToPath. process.envvalues are strings orundefined; coerce and default (Number(process.env.PORT) || 3000).- Never use the deprecated
new Buffer(...); useBuffer.from(...)orBuffer.alloc(n). (safety) - Pipe large files with
stream/promisespipeline()instead of buffering the whole payload.
// bad
exec(`ping ${userHost}`); // command injection
const token = Math.random().toString(36).slice(2); // predictable
// good
execFile('ping', ['-c', '4', userHost]);
const token = crypto.randomBytes(32).toString('base64url');import x from 'fs'->import x from 'node:fs'.readFileSync(...)in an async path ->await readFile(...)fromnode:fs/promises.dir + '/' + name->path.join(dir, name).exec(\cmd ${input}`)->execFile('cmd', [input])`. (safety)Math.random()for tokens/ids ->crypto.randomBytes/crypto.randomUUID. (safety)__dirnamein ESM ->import.meta.dirname.new Buffer(x)->Buffer.from(x)/Buffer.alloc(n). (safety)
- Prefer Vitest as the default for new projects (Vite-native, ESM/TS out of the box, Jest-compatible API). Use
node:testfor dependency-free libraries; Jest for React Native or large existing Jest suites. - Structure tests with
describe/itand Arrange-Act-Assert; one behavior per test. - Reset shared state in
beforeEach; order-dependent tests are flaky. - Use
vi.fn()spies over hand-rolledlet called = falseflags; keepvi.mockat module scope (it is hoisted); useimportOriginalfor partial mocks. - Mock at boundaries (network, filesystem, clock), never the unit under test.
- Use specific matchers:
toBe(identity) vstoEqual(deep); never assert onJSON.stringifyoutput.
expect(JSON.stringify(a)).toBe(...)->expect(a).toEqual(b).- module-level
letmutated across tests -> reset inbeforeEach. let called = falsespy flag ->vi.fn()+toHaveBeenCalled().vi.mockinsideitexpecting hoist ->vi.mockat module scope.
ESLint v9 makes flat config (eslint.config.js) the default; .eslintrc.* is deprecated. Start from js.configs.recommended, and put eslint-config-prettier last so it wins the stylistic overrides.
// eslint.config.js
import js from "@eslint/js";
import eslintConfigPrettier from "eslint-config-prettier/flat";
export default [
js.configs.recommended,
{
languageOptions: { ecmaVersion: 2024, sourceType: "module" },
rules: {
"no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
eqeqeq: ["error", "always"],
"prefer-const": "error",
"no-var": "error",
"no-throw-literal": "error",
},
},
eslintConfigPrettier, // MUST be last
];Core correctness rules: no-unused-vars, no-undef, eqeqeq, no-var, prefer-const, no-throw-literal, no-unsafe-finally, no-debugger.
- Prettier formats; ESLint lints. Run them separately and bridge conflicts with
eslint-config-prettier. Remove stylistic ESLint rules (indent,quotes,semi). - For type safety in plain JS, add
// @ts-check(orcheckJs) and annotate with JSDoc{Type}syntax; a missing annotation defaults toany. - Use
@ts-expect-error(flags stale suppressions) over@ts-ignore(silent forever). - Never commit
debugger;statements; useconsole.table/console.dir/console.traceover bareconsole.log, and ship source maps.
.eslintrc.json->eslint.config.js(flat config).eslint-config-prettiernot last -> make it the final array entry.indent/quotesESLint rules + Prettier -> remove stylistic rules, use Prettier.- JSDoc types with no
// @ts-check-> add// @ts-checkorcheckJs. // @ts-ignore->// @ts-expect-error(+ reason).- committed
debugger;-> remove (no-debugger).
- Use
const/let, nevervar. - Use
===/!==; the only exception isx == null. - Throw
Errorobjects, never strings. - Never leave a floating promise;
await,.catch(), orvoid ... .catch(). - Never feed untrusted data to
innerHTML,eval, ornew Function. (safety) - Use ESM with explicit extensions and the
node:prefix for built-ins.
2026-07-30 - Opus 4.8 (Cursor agent). Distilled from web research on modern JavaScript language, modules, async, errors, platform APIs, Node.js, security, testing, and tooling (2024-2026 sources).
