javascript

Write and review JavaScript: declarations, equality and coercion, functions, objects, iteration, classes, async and promise semantics, ES modules, regular expressions, JSDoc typing, and the ECMAScript

SKILL.md

GitHub

JavaScript fails by continuing. A mismatched type is coerced, a missing property is undefined, a rejected promise nobody watched is a warning. None of it reports itself, so the discipline is to make a failure loud at the earliest point it can be seen. Three biases decide most calls:

  • Prefer the construct that refuses over the one that converts — === over ==, ?? over ||, Number(s) over parseInt(s), a thrown error over a silent undefined.
  • The declared engine baseline decides what may be written, not the ECMAScript edition a feature belongs to.
  • Reach for the built-in globals first. A dependency earns its place by doing what Intl, URL, structuredClone, AbortController, and the iterator helpers do not.

Language Edition

Read the project's engine baseline before writing code: engines in package.json, browserslist, target and lib in tsconfig.json or jsconfig.json, .nvmrc, and the CI matrix. Where a transpiler stands between source and runtime, its target governs the output and the source may run ahead; where none does, the engine baseline's oldest engine governs the source.

An edition number is not an availability claim. Engines ship features years before ratification and lag on others, and the gap runs both ways inside a single edition: Array.fromAsync and Math.sumPrecise are both ES2026, yet the first shipped in Chrome 121 and reached Baseline widely available on 2026-07-25, while the second reached Baseline newly available on 2026-04-10 and is absent from Node.js 26. Check the feature, never the edition.

Floor edition per feature this skill's rules reference. Anything from ES2019 or earlier is available in every engine a project can realistically target.

  • ES2020 — ?., ??, globalThis, BigInt, Promise.allSettled, String.prototype.matchAll, dynamic import(), import.meta, specified for...in order
  • ES2021 — ??=, ||=, &&=, String.prototype.replaceAll, Promise.any, AggregateError, numeric separators
  • ES2022 — class fields, #private fields and methods, #x in obj, static {} blocks, top-level await, Object.hasOwn, .at(), Error cause, RegExp d flag
  • ES2023 — toSorted, toReversed, toSpliced, with, findLast, findLastIndex
  • ES2024 — Object.groupBy, Map.groupBy, Promise.withResolvers, RegExp v flag, String.prototype.isWellFormed
  • ES2025 — iterator helpers, Set methods (union, intersection, difference, …), Promise.try, RegExp.escape, RegExp modifiers (?i:), duplicate named capture groups, import attributes, JSON modules
  • ES2026 — Map.prototype.getOrInsert and getOrInsertComputed, Iterator.concat, Array.fromAsync, Error.isError, Math.sumPrecise, Uint8Array base64 and hex, JSON.rawJSON
  • Stage 4, unratified — using and await using with Symbol.dispose and DisposableStack; Temporal; Iterator.zip. No Safari 26.x release implements any of them, so none is safe for an engine baseline that includes Safari 26. using requires Chrome 134, Firefox 141, or Node.js 24; Temporal requires Chrome 144, Firefox 139, or Node.js 26. Atomics.pause is the exception, reaching Chrome 133, Firefox 137, and Safari 18.4.
  • Not shippable — decorators are Stage 2.7 and no engine implements them. A project using them runs TypeScript or Babel, and TypeScript's experimentalDecorators is the older, incompatible design.

Read [${CLAUDE_SKILL_DIR}/references/versions/es20NN.md] — one file per ratified edition, es2020.md through es2026.md, plus [${CLAUDE_SKILL_DIR}/references/versions/stage4-queue.md] for the unratified set — when writing against a feature near the engine baseline, and whenever raising it. Each carries what its edition added, what behavior it changed, and the traps it introduced.

Naming

  • One word per concept across the codebase. getUser everywhere, never getUser beside fetchUserInfo and loadCustomerRecord.
  • url, id, err, ctx, req, res, db, fn are the accepted abbreviations. Spell out everything else in code. A signature written to describe an API rather than to run — Object.groupBy(items, cb) — takes the placeholder name the documentation uses.
  • No redundant context: car.make, never car.carMake.
  • Name a value or a type for its role, never its structure. UserShape, UserObject, UserData, and userArray restate what the declaration already shows; User, users, UserPatch, and UserRow say what the value is for.
  • File names are kebab-case: user-service.js. Match the surrounding directory when it already differs.
  • A single-letter name is legal in a scope of one or two lines — a loop index, a comparator parameter — and nowhere else.

Declarations and Scope

  • Never var. It is function-scoped and hoists to undefined, which is why it survives only in code that predates block scoping.
  • const freezes the binding, not the value. A const object is still mutable.
  • One declaration per statement. const a = 1, b = 2 breaks under a debugger and under git blame.
  • Declare at first use, not at the top of the function. The distance between declaration and use is the variable's real scope.
  • A let that is assigned exactly once in every branch wants a ternary or a helper function returning const.

Equality and Coercion

  • === and !== always, with one exception: value == null tests null and undefined together, and the strict alternative is longer without being clearer.
  • ?? for defaults, || only when 0, "", and false genuinely mean "absent". Every configuration default is ??.
  • ?. where the absence is expected, never to silence a bug. Data that must be present throws at the access rather than surfacing as undefined three frames later. obj?.method() still throws when method is missing; write obj?.method?.() when both are optional.
  • ?? cannot be mixed with || or && without parentheses — it is a SyntaxError.
  • The falsy set is false, 0, -0, 0n, "", null, undefined, NaN. [], {}, and "0" are truthy.
  • Number.isNaN, never the global isNaN, which coerces first: isNaN("abc") is true.
  • Number(s) over parseInt(s) for a whole-string conversion. parseInt stops at the first invalid character, so parseInt("1e3") is 1. Never pass parseInt as a bare callback — map supplies the index as the radix, and ["1","7","11"].map(parseInt) is [1, NaN, 3].
  • Never compute money in a double. (1.005).toFixed(2) is "1.00". Hold integer minor units, and format with Intl.NumberFormat.
  • Object.is only for NaN and -0. Everywhere else it is === with extra ceremony.

Read [${CLAUDE_SKILL_DIR}/references/conventions/coercion.md] when a comparison behaves inconsistently between two built-ins, when a number loses precision, or when a copy shares state it should not — it carries the four equality algorithms and which built-in uses each, the -0 sources, BigInt mixing rules, property-order rules, and the prototype-pollution boundary.

Functions

  • Arrow functions for callbacks, function declarations for named module-level functions. A function declaration hoists, which lets helpers sit below the code that reads them.
  • Never an arrow function as an object method or on a prototype. It has no this of its own.
  • Guard clauses first, happy path unindented. A function whose body is one if wrapping everything wants an early return.
  • Destructure the parameter object at three or more parameters. Positional arguments past two are unreadable at the call site and impossible to extend.
  • Default parameters over || inside the body. They are evaluated left to right and may reference earlier parameters.
  • Rest parameters, never arguments — arguments is array-like, absent in arrow functions, and defeats optimization.
  • Return an object when returning several values. A tuple binds callers to positional order.
  • A closure keeps the whole scope alive, not the values it reads. A callback that captures one field of a large object retains the object.

Objects and Copying

  • Spread over Object.assign for a copy, because it does not fire setters on the target. Both are shallow and both drop the prototype.
  • structuredClone for a deep copy of data. It throws DataCloneError on functions and symbols, and discards the prototype, so a class instance clones to a plain object.
  • Object.hasOwn(obj, key), never obj.hasOwnProperty(key) — the latter breaks on a null-prototype object.
  • Dot notation for a known key, brackets only for a computed one.
  • Object.freeze is shallow. Freezing a tree means freezing every level, or not claiming immutability.
  • Never mutate a parameter, except a reduce accumulator whose initial value the same call created. Return a new value; the caller decides whether to keep the old one.
  • Never extend a built-in prototype. It is visible to every dependency in the process.

Arrays and Iteration

  • for...of for side effects, array methods for transformation, for only when the index is used. forEach cannot break and does not await; reach for it only when the callback is already a named function.
  • Never for...in on an array, and rarely on an object — it walks inherited enumerable keys.
  • Always pass a comparator to sort for numbers. The default is lexicographic: [1, 5, 10, 2].sort() is [1, 10, 2, 5]. The comparator must return a number; (a, b) => a > b returns booleans, which makes the order implementation-defined and the bug engine-dependent.
  • sort and reverse mutate. Prefer toSorted and toReversed where the engine baseline allows.
  • Never delete an array element. It leaves a hole and does not change length. Use splice or filter.
  • Build a dense array with Array.from({ length: n }) or [...Array(n)]. Array(n).map(f) calls f zero times, because array methods skip holes while spread and for...of treat them as undefined.
  • Map when keys are not strings, come from outside the program, or must keep insertion order. Object keys are stringified, and integer-like keys reorder themselves ahead of the rest.
  • Set for membership and deduplication — [...new Set(items)].
  • Grow an accumulator in place, never by copying it. { ...acc, [k]: v }, [...acc, x], acc.concat(x), and Object.assign({}, acc, x) copy everything gathered so far on every step, so the loop is quadratic. Measured on Node 26.9.0, a spread reduce over 8 000 items takes 3.7 s, and assigning into the accumulator takes under 1 ms. Assign or push into an accumulator the loop created, then return it. Object.fromEntries, Object.groupBy, and new Map(entries) replace most of these reductions outright.
  • Iterator helpers for a stream that is infinite, expensive, or larger than memory. They are lazy and single-pass: draining a helper drains its source.
  • Sort user-visible text with Intl.Collator. < compares UTF-16 code units, so "ä" sorts after "z".
  • Count characters with Intl.Segmenter, not .length. .length counts code units and [...str] counts code points; neither is a user-perceived character.

Read [${CLAUDE_SKILL_DIR}/references/conventions/iteration.md] when a loop produces the wrong count, when an array method skips elements, or when a generator's cleanup does not run — it carries the full hole-behavior split, the iterator-closing rules, and the Map/Set mutation-during-iteration semantics.

Classes

  • Reach for a class when a type has invariants, several methods over shared state, or a lifecycle. A class with one method and no state is a function.
  • #private fields, never a leading underscore. #x in obj is the only reliable same-class test; instanceof is forgeable and fails across realms.
  • Never call an overridable method from a constructor. The base constructor completes before any derived field initializer runs, so the method sees undefined.
  • A method belongs on the prototype; an arrow-function field is an own property per instance. Use the field form only where the function is detached from its receiver.
  • Composition over extends. Inherit only for a real "is-a" with a shared contract.
  • Omit a constructor that only calls super(...args).
  • Never rely on a built-in method to return your subclass. Array methods honor Symbol.species and the Set methods do not; structuredClone discards the prototype either way.

Read [${CLAUDE_SKILL_DIR}/references/conventions/classes.md] when a field reads undefined, when instanceof gives the wrong answer, or when designing a class hierarchy — it carries the full initialization order, the private-field semantics, and the prototype-pollution rules.

Async

  • Every promise is awaited or has a handler. send().catch(reportError) for fire-and-forget, which is the one place a .then belongs. A floating promise is a failure nobody will see.
  • .map(async …) needs a Promise.all around it, and forEach(async …) is always wrong — forEach discards the returned promise and finishes before any callback body does.
  • Start independent work together, await together: const [a, b] = await Promise.all([f(), g()]). Two sequential awaits with no dependency between them double the latency.
  • Bound a large fan-out. Promise.all over ten thousand items opens ten thousand operations.
  • Pick the combinator by failure mode — all when every result is required, allSettled when every outcome must be observed, any for the first success, race only with an AbortController, because the losers keep running.
  • return await inside a try, plain return outside it. return promise inside a try returns before the promise settles, so the catch never fires.
  • Never return, break, or continue out of a finally — it discards a pending throw.
  • AbortController is the cancellation protocol. Thread { signal } through; use AbortSignal.timeout(ms) for a deadline and AbortSignal.any([...]) to combine. Distinguish an abort from a real failure before retrying.
  • new Promise(...) only to wrap a callback API. Where a resolver must escape, Promise.withResolvers() returns { promise, resolve, reject }.
  • Sequential await in a loop is correct when each step depends on the last and a defect otherwise.
  • using and await using release a resource on every exit path where the engine baseline allows them; try/finally with one nested block per resource is the equivalent elsewhere.

Read [${CLAUDE_SKILL_DIR}/references/conventions/async.md] when ordering between promises and timers matters, when a rejection surfaces in the wrong place, or when cancellation has to propagate — it carries microtask ordering, the tick cost of await, the floating-promise catalog, and the abort-reason semantics.

Modules

  • An import is a live read-only binding, not a copy. The importer sees a reassignment by the exporter and cannot write to the binding itself. Code ported from require often depends on the copy without knowing it.
  • Never export a mutable let. Export a function that returns the value.
  • Named exports over default. A default export takes a different name in every importer, so a typo becomes undefined rather than an error. Accept one only where a framework requires it.
  • Include the file extension in every relative specifier — "./user.js". Extensionless and directory imports are bundler conventions, not module semantics.
  • Imports at the top, grouped by origin — built-in, external, internal — even though the language hoists them regardless of position.
  • No wildcard re-exports and no barrel files inside a package. Both hide the export set and defeat tree shaking. A package's single public entry point declared through exports is the exception.
  • Break a cycle rather than deferring the read. A cyclic binding read at module top level throws ReferenceError; reading it inside a function hides the cycle instead of removing it.
  • Import attributes are required, not advisory: import data from "./d.json" with { type: "json" }.
  • import() for genuinely conditional loading — a route, a heavy optional dependency, a flagged feature. Keep the specifier a literal so tooling can find it.
  • A side-effect import needs a comment saying why.

Read [${CLAUDE_SKILL_DIR}/references/conventions/modules.md] when a binding is undefined at module top level, when a module appears to run twice, or when converting between CommonJS and ESM — it carries live-binding semantics, the namespace object, evaluation order, and cycle resolution.

Errors

  • Handle an error once: recover from it, or let it propagate. A catch that logs and rethrows reports one failure at every frame.
  • Add context the underlying error does not carry, with cause: new Error("load config", { cause: err }). Never concatenate the inner message into the outer one.
  • Subclass Error and set this.name when a caller must branch on the failure. Match on the class, never on the message text.
  • Error.isError(v) over v instanceof Error where the engine baseline allows: instanceof is false across realms and true for a forged prototype.
  • An empty catch and a catch that only logs are the same defect.
  • Let a programmer error crash. A TypeError from a bug is not something to recover from at the call site.

Regular Expressions

  • Never share a g or y regex across calls. lastIndex persists, so the same test alternates true and false. Build it inside the function, or drop g when a boolean is all you need.
  • Named capture groups over positional. m.groups.year survives inserting a group; $1 does not.
  • matchAll requires g and throws TypeError without it. replaceAll throws on a regex without g.
  • Use the v flag for anything matching user text. It is a stricter superset of u: it adds set operations and properties of strings, and it requires escaping characters u allows raw, so the switch is not mechanical.
  • RegExp.escape before interpolating data into a pattern. An unescaped ( from user input is a SyntaxError and an unescaped (a+)+ is a denial of service.
  • Nested unbounded quantifiers over overlapping sets are a security bug, not a performance one. A pattern applied to external input needs bounded quantifiers or a parser.
  • A regex cannot parse a nested structure. Reach for URL, Intl.Segmenter, or a real parser.

Read [${CLAUDE_SKILL_DIR}/references/conventions/regexp.md] when a pattern matches inconsistently across calls or when migrating a pattern from u to v — it carries the statefulness rules, the v differences, and the backtracking shapes.

Intl and Built-in Globals

  • Never compare an Intl result to a literal and never parse one back. Output is permitted to differ between engines and CLDR releases, and it contains non-breaking and narrow-no-break spaces that are invisible in a diff. Assert with formatToParts, or snapshot.
  • Pin the locale in a test. An unqualified formatter follows the host default.
  • Hoist a formatter out of a loop. Construction searches the locale database; format does not.
  • Intl.ListFormat for joining a list, Intl.PluralRules for plural selection, Intl.Collator for sorting. arr.join(", ") and n === 1 ? "x" : "xs" are English-only and wrong even there.
  • URL and URLSearchParams for anything URL-shaped. String concatenation gets encoding wrong.
  • crypto.randomUUID and crypto.getRandomValues where a value must be unpredictable. Math.random is never suitable for a token or an identifier.

Read [${CLAUDE_SKILL_DIR}/references/conventions/intl.md] when formatting anything a user reads, or when a formatted string has to be tested — it carries the per-object routing, the reuse rules, and the built-in globals worth reaching for.

JSDoc and Documentation

  • JSDoc types are checked only with // @ts-check or checkJs. Without one, the annotations are decoration.
  • Annotate the module boundary and what inference cannot reach — exported symbols, an empty literal filled later, a value parsed from JSON. Never restate a type the checker already infers.
  • { b?: number } for an optional property, not { b: number= }, which is only valid on a @param.
  • Write {number | null}, not {?number}. Closure nullability syntax is legacy; Object and object degrade to any.
  • A doc comment on an exported symbol is API documentation and is expected even where code comments are not. Describe the contract, never the implementation, and update it in the edit that changes the signature.

Read [${CLAUDE_SKILL_DIR}/references/conventions/jsdoc.md] when typing a plain-JavaScript project or when a JSDoc type is silently ignored — it carries the supported tag set, @import and @satisfies, and the syntax that does not work.

Style Guides

The two style guides most often cited for JavaScript are frozen. Citing either as authority produces outdated advice.

  • The Google JavaScript Style Guide opens with "This guide is no longer being updated. Google recommends migrating to TypeScript." It targets ES6, mentions goog.module sixty-two times, requires all fields to be declared in the constructor, and contains no occurrence of #private, optional chaining, or nullish coalescing.
  • eslint-config-airbnb last released 19.0.4 in December 2021, and the last substantive change to the shared config was July 2022. Its rules predate class fields being universally available, iterator helpers, Set methods, .at(), structuredClone, and Object.groupBy.

Follow the rules in this skill and the project's own lint configuration. When a project's ESLint config extends one of these, follow the project and say once that the base config is frozen.

Code Navigation

typescript-language-server is configured for .js, .jsx, .mjs, .cjs, .ts, .tsx, .mts, and .cts. Use LSP tools rather than Grep or Glob for anything that is a JavaScript identifier — they resolve module specifiers, re-exports, aliases, and inferred types, which text search cannot.

  • Where a symbol is defined — goToDefinition
  • Every use of a symbol — findReferences
  • Type, signature, or doc of a symbol — hover
  • Symbols in one file — documentSymbol; across the project — workspaceSymbol
  • Types implementing an interface — goToImplementation
  • Call graph in either direction — incomingCalls, outgoingCalls

Grep and Glob stay correct for comments, string literals, log messages, environment variable names, CSS class names, config values, and file-name patterns. Subagents exploring JavaScript reach the same LSP server — instruct them to use it.

Application

When writing JavaScript, apply these conventions silently — do not narrate a rule while following it. Where existing code contradicts one, follow the codebase and flag the divergence once.

When reviewing JavaScript, cite the violation and show the fix inline. Do not lecture.

Bad:  "Best practice is to use nullish coalescing rather than logical OR for default values."
Good: options.retries || 3 -> options.retries ?? 3 — 0 is a valid retry count

Integration

The coding skill governs workflow — discovery, decomposition, verification. This skill governs JavaScript implementation choices and wins on any question of how JavaScript reads. Both are active at once.

The typescript skill extends this one and does not restate it. The nodejs and bun skills own their runtimes, and the split with this skill is the same in both directions: this skill owns module syntax and live-binding semantics, they own resolution; this skill owns the cancellation protocol and the globals that behave the same everywhere, they own which of their own APIs accept a signal and how their own fetch behaves. The vitest skill owns test authoring in Vitest, and each runtime skill owns the test runner it ships. Browser platform and framework concerns belong to the frontend plugin.

When in doubt, make the failure loud.