- name
- loom-javascript
- description
- Modern JavaScript (ES2022+) and Node.js expertise for idiomatic, production-quality code without a TypeScript build.
- triggers
- ["javascript","js","jsx","mjs","cjs","node","nodejs","node.js","esm","es modules","commonjs","package.json","jsdoc","ts-check","eslint","biome","vitest","jest","mocha","node:test","bun test","promise","async await","prototype pollution","event loop"]
# JavaScript Language Expertise
## Overview
Idiomatic ES2022+ JavaScript on Node.js 22+ (or Bun): ES modules, async/await, JSDoc types checked by `tsc`, the built-in `node:` APIs, and a small tool set. Assumes the reader knows the language. The content is decision rules and the traps that survive review: a floating promise, `==` on mixed types, a lost `this`, `JSON.parse` output merged into an object (prototype pollution), a `catch` that swallows the error, a test runner that finds zero files and exits 0. TypeScript syntax is covered by `loom-typescript`.
## Tooling
Use the package manager the repository's lockfile names. For new work prefer Bun: `bun add`, `bun remove`, `bunx`. Do not run `npm` or `npx` commands in this workflow.
| Task | Command |
| --- | --- |
| Install | `bun install` |
| Add a dependency | `bun add zod` (dev: `bun add -d vitest`) |
| Run a script | `bun run build` |
| Run a one-off tool | `bunx eslint .` |
| Run a file | `node src/main.js` or `bun src/main.js` |
| Type-check JSDoc | `bunx tsc --noEmit` |
| Lint | `bunx eslint .` or `bunx biome check .` |
| Format | `bunx biome format --write .` or `bunx prettier --write .` |
| Test, whole suite | `bun test` / `bunx vitest run` / `node --test` |
| Audit dependencies | `bun audit` |
- **Lockfile:** commit `bun.lock`. Install in CI with `bun install --frozen-lockfile`, which fails when `package.json` and the lockfile disagree.
- **Node version:** pin it in `package.json` (`"engines": {"node": ">=22"}`) and in `.nvmrc` or `.tool-versions`. Node 22+ ships `require(esm)`, `fetch`, `node:test`, `--watch` and `--env-file`.
- **Linting:** ESLint 9 uses a flat config (`eslint.config.js`); `.eslintrc*` is the legacy format. Biome is a single binary that lints and formats (`biome.json`) and replaces ESLint plus Prettier when the rule set it ships is enough. Pick one linter per repository.
Floating promises need type information. ESLint's `@typescript-eslint/no-floating-promises` works on JavaScript when `parserOptions.projectService` points at a `jsconfig.json`. Without it, review every call to an async function for an `await` or a `.catch`.
## Modules: ESM and CommonJS
A package is ESM when its `package.json` has `"type": "module"`; otherwise `.js` files are CommonJS. `.mjs` is always ESM and `.cjs` always CommonJS.
```javascript
// ESM
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import config from "./config.json" with { type: "json" };
export async function load(dir) {
return readFile(join(dir, "data.txt"), "utf8");
}
```
| Concern | ESM | CommonJS |
| --- | --- | --- |
| Syntax | `import` / `export` | `require` / `module.exports` |
| Loading | static, hoisted, async-capable (top-level `await`) | synchronous, at the call |
| `__dirname`, `__filename` | absent; use `import.meta.dirname` and `import.meta.filename` (Node 20.11+) | defined |
| File extensions | required in relative specifiers: `./util.js` | optional |
| JSON | `with { type: "json" }` import attribute | `require("./x.json")` |
| `this` at top level | `undefined` | `module.exports` |
- Built-in modules take the `node:` prefix (`node:fs`, `node:crypto`): it states that the import is built in and cannot be shadowed by an installed package.
- Use named exports. A default export lets every importer choose a different local name, which defeats search and rename.
- Interop: ESM imports CommonJS freely (`module.exports` becomes the default export); named imports from CommonJS work only when Node can statically detect them. CommonJS loads ESM with `require()` on Node 22.12+ when the graph has no top-level `await`; otherwise use `await import("./x.mjs")`.
- Circular imports resolve differently: ESM exports are live bindings and a cycle fails with a `ReferenceError` when a binding is read before its module has run; CommonJS hands back a partially filled `exports` object. Break the cycle by moving the shared piece into a third module.
- A package that ships to others declares an `"exports"` map; it also blocks deep imports of files that are not listed.
```json
{
"name": "@acme/fmt",
"type": "module",
"exports": {
".": { "import": "./src/index.js", "require": "./dist/index.cjs" },
"./package.json": "./package.json"
}
}
```
## Language Rules
### Values and equality
- `===` and `!==` always. `==` coerces: `0 == ""`, `"1" == 1` and `null == undefined` are all true. The one defensible use is `value == null` to test for `null` or `undefined` together; ESLint's `eqeqeq` option `["error", "always", { null: "ignore" }]` allows it.
- Use `Number.isNaN`, never `x === NaN` (always false) or the coercing global `isNaN`. `Object.is(a, b)` distinguishes `-0` and `+0`.
- `typeof null` is `"object"`. `Array.isArray` identifies arrays. `typeof` an undeclared name is `"undefined"` and does not throw.
- `||` replaces every falsy value (`0`, `""`, `false`); `??` replaces only `null` and `undefined`. Defaults for numbers and strings use `??`, and `??=` assigns when nullish.
- Numbers are IEEE doubles: `0.1 + 0.2 !== 0.3`, integers are exact only up to `Number.MAX_SAFE_INTEGER`. Use `BigInt` (`10n ** 20n`) for ids and money in minor units beyond that; `BigInt` does not mix with `Number` in arithmetic and `JSON.stringify` throws on it.
### Declarations and functions
- `const` by default, `let` when reassigned, never `var` (function scope, hoisted). Loop closures over `let` capture one binding per iteration.
- Arrow functions capture `this` lexically and have no `arguments`; use them for callbacks. Methods that rely on `this` are written as methods or `function`, and a method passed as a callback loses its `this`:
```javascript
class Counter {
count = 0;
inc = () => { this.count += 1; }; // field arrow: bound per instance
dec() { this.count -= 1; }
}
const c = new Counter();
const { dec } = c;
// dec(); // TypeError: this is undefined in a module
const bound = c.dec.bind(c);
bound();
```
- Parameters: destructure options objects with defaults (`function open({ path, mode = "r" } = {})`) rather than long positional lists. A default value is evaluated per call, so `function f(list = [])` is safe.
- Prefer `Map` and `Set` over plain objects for dynamic keys: no prototype collisions, any key type, `.size`, insertion order. `Object.create(null)` or `Object.hasOwn(obj, key)` for objects used as dictionaries.
### Collections and data
| Need | Use |
| --- | --- |
| Copy an array without mutating | `toSorted()`, `toReversed()`, `toSpliced()`, `with(i, v)` (Node 20+) |
| Last element | `arr.at(-1)` |
| Group by key | `Object.groupBy(items, fn)` or `Map.groupBy` (Node 21+) |
| Deep copy | `structuredClone(value)`; `JSON.parse(JSON.stringify(x))` drops `undefined`, `Date` and `Map` |
| Merge objects | spread `{ ...a, ...b }` (shallow) |
| Unique values | `[...new Set(arr)]` |
| Iterate entries | `for (const [k, v] of Object.entries(o))` |
- `Array.prototype.sort` without a comparator sorts as strings: `[10, 9, 1].sort()` gives `[1, 10, 9]`. Pass `(a, b) => a - b`.
- `for...in` walks inherited enumerable keys; use `for...of` over `Object.keys`, `Object.entries` or the iterable itself. `forEach` ignores `await` inside its callback.
## Async and Errors
An `async` function always returns a promise, and a throw inside it rejects that promise. A promise that rejects with no handler crashes Node (`unhandledRejection` is fatal since Node 15).
```javascript
// Floating promise: the rejection has no handler and the caller never waits
function save(user) {
db.insert(user); // missing await
}
// Correct
async function save(user) {
await db.insert(user);
}
```
- Every call to an async function is awaited, returned, or explicitly detached with a `.catch` that logs. `void fireAndForget().catch(log)` states the intent.
- `await` in a loop serialises. Independent work runs with `Promise.all`; bound the fan-out for large lists (a worker pool or `p-limit`), since 10 000 simultaneous requests exhaust sockets.
- `Promise.all` rejects on the first failure and leaves the others running. `Promise.allSettled` waits for all and reports each outcome; `Promise.any` resolves with the first success; `Promise.race` settles with the first result of either kind.
- `return await` inside `try` is required for the `catch` to see the rejection; outside `try` it is redundant.
- Wrap callback APIs once with `util.promisify`, or use the `node:fs/promises`, `node:timers/promises` and `node:stream/promises` variants.
- Cancellation: pass an `AbortSignal` (`AbortSignal.timeout(5000)`, `AbortSignal.any([a, b])`) to `fetch`, `fs/promises`, `timers/promises` and your own functions, and check `signal.aborted` or `signal.throwIfAborted()` between steps.
```javascript
async function getJson(url, { timeoutMs = 5000 } = {}) {
const res = await fetch(url, { signal: AbortSignal.timeout(timeoutMs) });
if (!res.ok) {
throw new Error(`GET ${url} failed: ${res.status}`, { cause: new HttpError(res.status) });
}
return res.json();
}
class HttpError extends Error {
constructor(status) {
super(`HTTP ${status}`);
this.name = "HttpError";
this.status = status;
}
}
```
- Throw `Error` objects (they carry a stack), never strings. Pass the original through `{ cause }` when wrapping. Subclass `Error` for conditions a caller branches on, and test with `instanceof` or a stable `code` property.
- `fetch` resolves on HTTP 4xx and 5xx: check `res.ok`. It rejects only on network failure and abort.
- An empty `catch {}` is a defect. Handle the error, rethrow, or log it with the reason it is safe to ignore.
- Process level: register `process.on("unhandledRejection", ...)` only to log and exit non-zero; continuing in an unknown state hides the bug. Handle `SIGTERM` to close servers and flush work before `process.exit`.
### Event loop
- A synchronous loop of 200 ms blocks every request on the process. Move CPU work (hashing large input, parsing megabytes of JSON, image work) to `node:worker_threads` or a child process.
- Order inside one turn: synchronous code, then `process.nextTick`, then promise microtasks, then timers and IO callbacks. `queueMicrotask` and a recursive `nextTick` can starve IO.
- `*Sync` filesystem and crypto calls are acceptable at start-up and in CLIs and a defect in a request handler.
- Streams: use `pipeline` from `node:stream/promises` so errors propagate and handles close; `readable.pipe()` leaks the destination on error. `for await (const chunk of stream)` respects backpressure.
## Node.js APIs
| Need | API |
| --- | --- |
| Files | `node:fs/promises` (`readFile`, `writeFile`, `mkdir({ recursive: true })`, `rm({ recursive: true, force: true })`) |
| Paths | `node:path` (`join`, `resolve`, `relative`), `node:url` (`fileURLToPath`) |
| HTTP client | global `fetch`; server: `node:http` or a framework |
| Hashing, random | `node:crypto` (`randomUUID`, `randomBytes`, `createHash`, `timingSafeEqual`) |
| Child processes | `node:child_process` (`execFile`, `spawn`) |
| CLI arguments | `node:util` `parseArgs` |
| Config | `node --env-file=.env app.js`; read `process.env.NAME` once at start-up and validate |
| Test | `node:test` with `node:assert/strict` |
| Timing | `performance.now()`, `node:timers/promises` `setTimeout` |
| Concurrency | `node:worker_threads`, `AsyncLocalStorage` for request context |
```javascript
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const run = promisify(execFile);
// An argument array, no shell: user input cannot add commands.
const { stdout } = await run("git", ["log", "-1", "--format=%H", ref], { timeout: 10_000 });
```
## Types Without TypeScript
JSDoc plus `// @ts-check` gives the editor and `tsc` the same checking as `.ts` files with no build step. Enable it for the whole project in `jsconfig.json` or `tsconfig.json` and run `bunx tsc --noEmit` in CI.
```json
{
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"noEmit": true,
"strict": true,
"module": "NodeNext",
"moduleResolution": "NodeNext",
"target": "ES2022"
},
"include": ["src", "test"]
}
```
Auf GitHub ansehen