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.mdplugins/javascript/skills/javascript/SKILL.md
GitHubJavaScript 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)overparseInt(s), a thrown error over a silentundefined. - 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, dynamicimport(),import.meta, specifiedfor...inorder - ES2021 —
??=,||=,&&=,String.prototype.replaceAll,Promise.any,AggregateError, numeric separators - ES2022 — class fields,
#privatefields and methods,#x in obj,static {}blocks, top-levelawait,Object.hasOwn,.at(),Errorcause, RegExpdflag - ES2023 —
toSorted,toReversed,toSpliced,with,findLast,findLastIndex - ES2024 —
Object.groupBy,Map.groupBy,Promise.withResolvers, RegExpvflag,String.prototype.isWellFormed - ES2025 — iterator helpers,
Setmethods (union,intersection,difference, …),Promise.try,RegExp.escape, RegExp modifiers(?i:), duplicate named capture groups, import attributes, JSON modules - ES2026 —
Map.prototype.getOrInsertandgetOrInsertComputed,Iterator.concat,Array.fromAsync,Error.isError,Math.sumPrecise,Uint8Arraybase64 and hex,JSON.rawJSON - Stage 4, unratified —
usingandawait usingwithSymbol.disposeandDisposableStack;Temporal;Iterator.zip. No Safari 26.x release implements any of them, so none is safe for an engine baseline that includes Safari 26.usingrequires Chrome 134, Firefox 141, or Node.js 24;Temporalrequires Chrome 144, Firefox 139, or Node.js 26.Atomics.pauseis 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
experimentalDecoratorsis 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.
getUsereverywhere, nevergetUserbesidefetchUserInfoandloadCustomerRecord. url,id,err,ctx,req,res,db,fnare 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, nevercar.carMake. - Name a value or a type for its role, never its structure.
UserShape,UserObject,UserData, anduserArrayrestate what the declaration already shows;User,users,UserPatch, andUserRowsay 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 toundefined, which is why it survives only in code that predates block scoping. constfreezes the binding, not the value. Aconstobject is still mutable.- One declaration per statement.
const a = 1, b = 2breaks under a debugger and undergit 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
letthat is assigned exactly once in every branch wants a ternary or a helper function returningconst.
Equality and Coercion
===and!==always, with one exception:value == nulltestsnullandundefinedtogether, and the strict alternative is longer without being clearer.??for defaults,||only when0,"", andfalsegenuinely 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 asundefinedthree frames later.obj?.method()still throws whenmethodis missing; writeobj?.method?.()when both are optional.??cannot be mixed with||or&&without parentheses — it is aSyntaxError.- The falsy set is
false,0,-0,0n,"",null,undefined,NaN.[],{}, and"0"are truthy. Number.isNaN, never the globalisNaN, which coerces first:isNaN("abc")istrue.Number(s)overparseInt(s)for a whole-string conversion.parseIntstops at the first invalid character, soparseInt("1e3")is1. Never passparseIntas a bare callback —mapsupplies 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 withIntl.NumberFormat. Object.isonly forNaNand-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,
functiondeclarations for named module-level functions. Afunctiondeclaration 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
thisof its own. - Guard clauses first, happy path unindented. A function whose body is one
ifwrapping 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—argumentsis 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.assignfor a copy, because it does not fire setters on the target. Both are shallow and both drop the prototype. structuredClonefor a deep copy of data. It throwsDataCloneErroron functions and symbols, and discards the prototype, so a class instance clones to a plain object.Object.hasOwn(obj, key), neverobj.hasOwnProperty(key)— the latter breaks on a null-prototype object.- Dot notation for a known key, brackets only for a computed one.
Object.freezeis shallow. Freezing a tree means freezing every level, or not claiming immutability.- Never mutate a parameter, except a
reduceaccumulator 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...offor side effects, array methods for transformation,foronly when the index is used.forEachcannotbreakand does not await; reach for it only when the callback is already a named function.- Never
for...inon an array, and rarely on an object — it walks inherited enumerable keys. - Always pass a comparator to
sortfor numbers. The default is lexicographic:[1, 5, 10, 2].sort()is[1, 10, 2, 5]. The comparator must return a number;(a, b) => a > breturns booleans, which makes the order implementation-defined and the bug engine-dependent. sortandreversemutate. PrefertoSortedandtoReversedwhere the engine baseline allows.- Never
deletean array element. It leaves a hole and does not changelength. Usespliceorfilter. - Build a dense array with
Array.from({ length: n })or[...Array(n)].Array(n).map(f)callsfzero times, because array methods skip holes while spread andfor...oftreat them asundefined. Mapwhen 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.Setfor membership and deduplication —[...new Set(items)].- Grow an accumulator in place, never by copying it.
{ ...acc, [k]: v },[...acc, x],acc.concat(x), andObject.assign({}, acc, x)copy everything gathered so far on every step, so the loop is quadratic. Measured on Node 26.9.0, a spreadreduceover 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, andnew 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..lengthcounts 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.
#privatefields, never a leading underscore.#x in objis the only reliable same-class test;instanceofis 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.speciesand theSetmethods do not;structuredClonediscards 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.thenbelongs. A floating promise is a failure nobody will see. .map(async …)needs aPromise.allaround it, andforEach(async …)is always wrong —forEachdiscards 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.allover ten thousand items opens ten thousand operations. - Pick the combinator by failure mode —
allwhen every result is required,allSettledwhen every outcome must be observed,anyfor the first success,raceonly with anAbortController, because the losers keep running. return awaitinside atry, plainreturnoutside it.return promiseinside atryreturns before the promise settles, so thecatchnever fires.- Never
return,break, orcontinueout of afinally— it discards a pending throw. AbortControlleris the cancellation protocol. Thread{ signal }through; useAbortSignal.timeout(ms)for a deadline andAbortSignal.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
awaitin a loop is correct when each step depends on the last and a defect otherwise. usingandawait usingrelease a resource on every exit path where the engine baseline allows them;try/finallywith 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
requireoften 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
undefinedrather 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
exportsis 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
catchthat 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
Errorand setthis.namewhen a caller must branch on the failure. Match on the class, never on the message text. Error.isError(v)overv instanceof Errorwhere the engine baseline allows:instanceofisfalseacross realms andtruefor a forged prototype.- An empty
catchand acatchthat only logs are the same defect. - Let a programmer error crash. A
TypeErrorfrom a bug is not something to recover from at the call site.
Regular Expressions
- Never share a
goryregex across calls.lastIndexpersists, so the same test alternates true and false. Build it inside the function, or dropgwhen a boolean is all you need. - Named capture groups over positional.
m.groups.yearsurvives inserting a group;$1does not. matchAllrequiresgand throwsTypeErrorwithout it.replaceAllthrows on a regex withoutg.- Use the
vflag for anything matching user text. It is a stricter superset ofu: it adds set operations and properties of strings, and it requires escaping charactersuallows raw, so the switch is not mechanical. RegExp.escapebefore interpolating data into a pattern. An unescaped(from user input is aSyntaxErrorand 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
Intlresult 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 withformatToParts, 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;
formatdoes not. Intl.ListFormatfor joining a list,Intl.PluralRulesfor plural selection,Intl.Collatorfor sorting.arr.join(", ")andn === 1 ? "x" : "xs"are English-only and wrong even there.URLandURLSearchParamsfor anything URL-shaped. String concatenation gets encoding wrong.crypto.randomUUIDandcrypto.getRandomValueswhere a value must be unpredictable.Math.randomis 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-checkorcheckJs. 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;Objectandobjectdegrade toany. - 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.modulesixty-two times, requires all fields to be declared in the constructor, and contains no occurrence of#private, optional chaining, or nullish coalescing. eslint-config-airbnblast 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,Setmethods,.at(),structuredClone, andObject.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.