Modern JavaScript skill for Codex
Newer built-ins, checked against your real targets. Codex reads browserslist, engines and the transpiler setup before using any newer feature, separating syntax a compiler can down-level from runtime APIs that need real support. It swaps lodash and moment calls for platform built-ins one call site at a time, flags semantic traps such as || versus ?? on zero, and drops a dependency only when nothing still imports it.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- javascript-modern Installs to ~/.agents/skills/javascript-modern/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install javascript-modern The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project javascript-modern Installs to .agents/skills/javascript-modern/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/javascript-modern.zipunzip -o javascript-modern.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) javascript-modern PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $javascript-modern, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Replaces legacy JavaScript patterns with modern built-ins checked against the project's real browser and Node targets - optional chaining, nullish coalescing, structuredClone, non-mutating array methods, Object.groupBy, Set methods, iterator helpers, Promise.withResolvers, AbortSignal.timeout, private fields and async iteration - and removes lodash, moment and polyfills that the platform now covers. Use when a user asks to modernize old JS, drop a utility dependency, fix var or callback-heavy code, or check whether a newer feature is safe for their browserslist or Node version.
Use it when
- A codebase still uses var, callbacks or manual prototype tricks
- Utility libraries duplicate what modern JavaScript ships
- You need to know whether a newer API runs on your supported browsers or Node
- Async code is serialized or mixes forEach with await
Use something else when
- The task is converting require and module.exports to import: use node-esm-migration
- You are setting up lint rules for the codebase: use eslint-setup
How to check the result
Tests pass unchanged, the features used are supported by the declared browserslist and engines targets, and removed dependencies no longer appear in any import.
The mistake it prevents
Replacing || with ?? everywhere, which changes behavior wherever a legitimate 0, empty string or false used to fall back to the default.
Done well means: every feature used is supported by the project’s declared targets (not the developer’s browser), each replacement keeps identical behavior including edge cases, and dependencies or polyfills are removed only when nothing else needs them.
Before changing anything
- Read
AGENTS.mdand find the targets:browserslist(inpackage.jsonor.browserslistrc),engines.node, tsconfigtarget/lib, bundlerbuild.target. - Check whether a transpiler covers syntax (TypeScript, Babel, esbuild, SWC). Syntax can be
down-leveled; runtime APIs (
structuredClone,Object.groupBy) cannot without polyfills. - Check support for each API you plan to use: MDN compatibility tables or Baseline status
(“Baseline widely available” = supported in all major browsers for 30+ months). Offline,
rely on
npx browserslistoutput plus the versions below and say so. - Make sure tests cover the code; idiom changes must not change behavior.
Syntax: safe nearly everywhere
| Legacy | Modern | Watch out |
|---|---|---|
var | const by default, let when reassigned | Block scoping changes closures in loops (usually fixes bugs) |
a && a.b && a.b.c | a?.b?.c | Returns undefined, not false/null, when short-circuiting |
x || 10 for defaults | x ?? 10 | || also replaced 0, "", false; decide which you meant |
obj.x = obj.x || {} | obj.x ??= {} | Same semantics question as above |
Object.assign({}, a, b) | { ...a, ...b } | Spread does not invoke setters on the target |
arguments, fn.apply(null, arr) | rest params, fn(...arr) | Spread of huge arrays can exceed argument limits |
function(){}.bind(this) callbacks | arrow functions | Arrows have no own this/arguments; do not convert methods that rely on dynamic this |
_private naming convention | #private fields | Truly inaccessible; breaks tests that poke at internals and proxies |
.then chains | async/await with try/catch | Keep independent awaits parallel with Promise.all |
| IIFE for async startup | top-level await (ESM only) | Blocks importers until resolved |
Built-ins that replace helpers
| Legacy or library | Modern | Notes |
|---|---|---|
JSON.parse(JSON.stringify(x)), _.cloneDeep | structuredClone(x) | Handles Dates, Maps, cycles; throws on functions and DOM nodes; drops class prototypes |
arr[arr.length - 1] | arr.at(-1) | |
arr.slice().sort(), .reverse() copies | toSorted(), toReversed(), toSpliced(), with(i, v) | ES2023; non-mutating |
_.findLast, reverse-then-find | findLast, findLastIndex | |
_.groupBy, reduce into object | Object.groupBy(items, fn), Map.groupBy | ES2024; result has null prototype |
Object.prototype.hasOwnProperty.call(o, k) | Object.hasOwn(o, k) | |
_.uniq, _.intersection | [...new Set(a)], setA.intersection(setB), union, difference | Set methods are ES2025; check targets |
_.flatten, _.flatMap | arr.flat(depth), arr.flatMap(fn) | |
Object.keys(o).map(k => [k, o[k]]) | Object.entries(o), Object.fromEntries(pairs) | |
Deferred pattern with outer let resolve | const { promise, resolve, reject } = Promise.withResolvers() | ES2024 |
Manual timeout with setTimeout + abort | fetch(url, { signal: AbortSignal.timeout(5000) }) | Combine signals with AbortSignal.any([a, b]) |
moment | Intl.DateTimeFormat, Intl.RelativeTimeFormat, date-fns, or Temporal where supported | Moment is in maintenance mode |
_.debounce | Keep a small helper; no built-in | Not everything has a platform replacement |
new Error(msg) losing the original | new Error(msg, { cause: err }) | Logged by Node and browsers |
| Converting iterators to arrays to map | Iterator helpers: iter.map().filter().take(5).toArray() | ES2025; lazy |
str.replace(/x/g, y) | str.replaceAll("x", y) | String argument needs no escaping |
Do not convert lodash wholesale. Replace calls one by one, keep helpers that have no exact
equivalent (debounce, throttle, deep merge, get with paths), and remove the
dependency only when rg "from 'lodash" and require('lodash return nothing.
Async patterns
// Parallel independent work, fail fast
const [user, orders] = await Promise.all([getUser(id), getOrders(id)]);
// Parallel, keep partial results
const results = await Promise.allSettled(urls.map((u) => fetch(u)));
const failed = results.filter((r) => r.status === "rejected");
// Bounded concurrency without a library
async function mapLimit(items, limit, fn) {
const out = new Array(items.length);
let next = 0;
async function worker() {
while (next < items.length) {
const i = next++;
out[i] = await fn(items[i], i);
}
}
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker));
return out;
}
// Async iteration over streams and paginated APIs
for await (const page of paginate("/api/items")) render(page);
Never await inside a for loop over independent items unless ordering or rate limiting
is the point. Never mix async functions with forEach; it does not wait.
Callbacks to promises (Node)
import { readFile } from "node:fs/promises";
import { setTimeout as sleep } from "node:timers/promises";
import { promisify } from "node:util";
const data = await readFile(path, "utf8");
await sleep(250);
const legacyAsync = promisify(legacyCallbackFn);
Use the node: prefix for built-in modules.
Newer features: check before using
| Feature | Status to verify |
|---|---|
using / await using (explicit resource management) | Shipping in recent V8 (Chrome, Node 24+) and TypeScript 5.2+ down-leveling; not in every browser yet |
Temporal | Shipping in some browsers only; use @js-temporal/polyfill or date-fns elsewhere |
RegExp.escape, Promise.try, Float16Array | ES2025; recent engines only |
| Decorators (TC39 version) | Need a transpiler; different from TypeScript experimentalDecorators |
Import attributes (with { type: "json" }) | Bundlers and Node 22+; old assert syntax is removed |
If the target matrix lacks support, either keep the legacy code, add a targeted polyfill
(core-js imports for that feature only), or raise the target with the user’s agreement.
Never add a polyfill for something every target already has.
Automate the safe parts
- ESLint core rules:
no-var,prefer-const,prefer-object-has-own,prefer-spread,prefer-rest-params,object-shorthand, andeslint-plugin-unicornrules such asprefer-at,prefer-structured-clone,no-array-for-each(judgment needed). eslint-plugin-compatoreslint-plugin-es-xflags APIs your browserslist cannot run.- Big repos: codemods (
$codemods) for mechanical rewrites; hand-review semantic ones like||to??.
Gotchas
??vs||: replacingcount || 10withcount ?? 10changes behavior whencountis 0. Decide per call site, and add a test for the falsy case.structuredCloneloses class identity:instanceof MyClassis false on the clone.Object.groupByreturns a null-prototype object;result.hasOwnPropertyis undefined.toSorted()with no comparator sorts as strings ([10, 9].toSorted()gives[10, 9]), same assort(). Always pass a comparator for numbers.- Arrow functions as object methods or prototype methods lose dynamic
this. for...initerates inherited enumerable keys; useObject.keys/entriesorfor...of.- Private
#fieldsdo not survivestructuredClone,JSON.stringify, or Vue/MobX proxies.
Verification
- Tests pass with no edits to assertions.
npx browserslistmatches what you checked;eslint-plugin-compat(if configured) is clean.- Bundle size or dependency count did not grow; report removed dependencies.
- For each
||to??or similar semantic change, the PR lists the call site and why the falsy case is now handled correctly.
What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Modern JavaScript"
short_description: "Newer built-ins, checked against your real targets"
default_prompt: "$javascript-modern Modernize this old jQuery-era utility file to current JavaScript" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.