Modern Features and Gotchas
Two things fill this page: the bugs that catch competent people repeatedly, and the modern features worth reaching for. Both are collected here rather than scattered, so it works as a checklist.
Floating-point arithmetic
Section titled “Floating-point arithmetic”Every JavaScript number is a 64-bit binary float. Decimal fractions like 0.1 have no exact binary representation, so small errors accumulate.
0.1 + 0.2; // => 0.300000000000000040.1 + 0.2 === 0.3; // => false0.3 - 0.1; // => 0.199999999999999981.005 * 100; // => 100.49999999999999Math.round(1.005 * 100) / 100; // => 1 (not 1.01)Never store money as a float. Work in the smallest unit — integer cents — and divide only for display.
const priceCents = 1999;const taxCents = Math.round(priceCents * 0.08); // => 160const totalCents = priceCents + taxCents; // => 2159(totalCents / 100).toFixed(2); // => '21.59'For comparison, use a tolerance:
const closeEnough = (a, b, eps = 1e-9) => Math.abs(a - b) < eps;closeEnough(0.1 + 0.2, 0.3); // => trueLarge integers lose precision above 2^53 − 1:
9007199254740993; // => 9007199254740992 ← the literal is already wrongNumber.isSafeInteger(2 ** 53); // => false9007199254740993n; // => exact, as a BigIntRounding is also not what you expect: toFixed uses the underlying binary value, and Math.round rounds .5 toward positive infinity.
(2.345).toFixed(2); // => '2.35'(1.005).toFixed(2); // => '1.00' ← 1.005 is actually 1.00499999...Math.round(-2.5); // => -2 (not -3)Math.trunc(-2.7); // => -2 (toward zero)Math.floor(-2.7); // => -3 (toward -Infinity)Coercion traps
Section titled “Coercion traps”'5' + 3; // => '53' (+ concatenates if either side is a string)'5' - 3; // => 2[] + []; // => ''[] + {}; // => '[object Object]'[1, 2] + [3]; // => '1,23''5' * '2'; // => 10true + true; // => 2[] == false; // => truenull >= 0; // => true, but null > 0 is false and null == 0 is falseThe reliable defenses:
- Compare with
===, never==(exceptx == null). - Convert explicitly:
Number(x),String(x),Boolean(x). - Never
+values whose types you have not established.
const total = Number(a) + Number(b);const label = `${count} items`; // template literals convert predictablyThe most common real-world instance is arithmetic on form input, which is always a string:
const qty = document.querySelector('#qty').value; // => '2'qty + 1; // => '21'Number(qty) + 1; // => 3A catalog of common bugs
Section titled “A catalog of common bugs”Object mutation through a shared reference
Section titled “Object mutation through a shared reference”const defaults = { retries: 3, tags: [] };
function makeConfig(overrides) { return Object.assign(defaults, overrides); // MUTATES defaults}makeConfig({ retries: 5 });defaults.retries; // => 5 — polluted for everyonefunction makeConfig(overrides) { return { ...defaults, ...overrides }; // new object each time}Spread is shallow, so tags is still shared. Use structuredClone(defaults) when nested state matters.
sort mutates and sorts as strings
Section titled “sort mutates and sorts as strings”const scores = [10, 9, 2];const sorted = scores.sort(); // => [10, 2, 9] AND scores is reordered
const fixed = scores.toSorted((a, b) => a - b); // ES2023, non-mutatingAsync inside forEach
Section titled “Async inside forEach”items.forEach(async (item) => { await save(item); });console.log('done'); // runs immediately — nothing has been savedfor (const item of items) await save(item); // sequentialawait Promise.all(items.map((item) => save(item))); // parallelFalsy defaults with ||
Section titled “Falsy defaults with ||”function setVolume(v) { const volume = v || 50; // muting with v = 0 silently jumps to 50 const better = v ?? 50; // 0 stays 0}The var loop closure
Section titled “The var loop closure”for (var i = 0; i < 3; i++) setTimeout(() => console.log(i)); // => 3, 3, 3for (let i = 0; i < 3; i++) setTimeout(() => console.log(i)); // => 0, 1, 2Lost this
Section titled “Lost this”const timer = { count: 0, tick() { this.count++; } };setInterval(timer.tick, 1000); // this is not `timer`setInterval(() => timer.tick(), 1000); // fixedtypeof null and array checks
Section titled “typeof null and array checks”typeof null; // => 'object'typeof []; // => 'object'Array.isArray([]); // => truevalue === null; // the correct null checkNaN comparisons
Section titled “NaN comparisons”NaN === NaN; // => false[1, NaN].indexOf(NaN); // => -1[1, NaN].includes(NaN); // => trueNumber.isNaN(NaN); // => true (never use the global isNaN)Comparing objects by value
Section titled “Comparing objects by value”{ a: 1 } === { a: 1 }; // => false — identity, not structure[1, 2].toString() === [1, 2].toString(); // => true, but fragileDate months are zero-based
Section titled “Date months are zero-based”new Date(2026, 0, 15); // => 15 January 2026 — month 0 is Januarynew Date(2026, 12, 1); // => 1 January 2027 — it overflows silentlyParsing is worse: new Date('2026-08-09') is treated as UTC midnight (date-only ISO form), while new Date('2026-08-09T00:00') is local. That one-line difference produces off-by-one-day bugs across timezones.
Number parsing
Section titled “Number parsing”parseInt('08'); // => 8 (safe in modern engines, but always pass a radix)parseInt('0x10', 10); // => 0 — stops at 'x'parseInt('12abc', 10); // => 12 — lenientNumber('12abc'); // => NaN — strictNumber(''); // => 0 — surprisingNumber(null); // => 0Number(undefined); // => NaNModifying an array while iterating
Section titled “Modifying an array while iterating”const arr = [1, 2, 2, 3];for (let i = 0; i < arr.length; i++) { if (arr[i] % 2 === 0) arr.splice(i, 1); // removing shifts the rest left}arr; // => [1, 2, 3] — the second 2 was skipped entirely
// Build a new array instead[1, 2, 2, 3].filter((n) => n % 2 !== 0); // => [1, 3]Assignment in a condition
Section titled “Assignment in a condition”if (x = 5) { } // always truthy — assigns, does not compareif (x === 5) { }Reading a property of undefined
Section titled “Reading a property of undefined”user.address.city; // TypeError if address is missinguser.address?.city ?? 'unknown'; // => 'unknown'Modern features worth using
Section titled “Modern features worth using”Optional chaining and nullish coalescing (ES2020)
Section titled “Optional chaining and nullish coalescing (ES2020)”const city = response?.data?.user?.address?.city ?? 'unknown';config.onReady?.(); // call only if definedconst first = list?.[0];Logical assignment (ES2021)
Section titled “Logical assignment (ES2021)”options.retries ??= 3; // only if null/undefinedcache.items ||= []; // only if falsyflags.debug &&= isDev; // only if already truthySpread and rest
Section titled “Spread and rest”const merged = { ...defaults, ...userOptions };const [head, ...tail] = list;const { id, ...rest } = record;const max = Math.max(...numbers);const unique = [...new Set(values)];structuredClone
Section titled “structuredClone”const deep = structuredClone(state); // real deep copy: cycles, Date, Map, SetAvailable in modern browsers and Node 17+. Throws on functions and symbols; class instances come back as plain objects.
Array.prototype.at (ES2022)
Section titled “Array.prototype.at (ES2022)”const list = [1, 2, 3];list.at(-1); // => 3list.at(-2); // => 2'hello'.at(-1); // => 'o'Object.hasOwn (ES2022)
Section titled “Object.hasOwn (ES2022)”Object.hasOwn(obj, 'key'); // safe even for Object.create(null) objectsNon-mutating array methods (ES2023)
Section titled “Non-mutating array methods (ES2023)”arr.toSorted(cmp); arr.toReversed();arr.toSpliced(1, 2); arr.with(0, 'new');arr.findLast(fn); arr.findLastIndex(fn);Object.groupBy and Map.groupBy (ES2024)
Section titled “Object.groupBy and Map.groupBy (ES2024)”const people = [ { name: 'Ada', dept: 'eng' }, { name: 'Grace', dept: 'eng' }, { name: 'Alan', dept: 'math' },];
Object.groupBy(people, (p) => p.dept);// => { eng: [{...Ada}, {...Grace}], math: [{...Alan}] }Object.groupBy returns a null-prototype object; Map.groupBy returns a Map and allows non-string keys. Both are recent — check support for your target runtime (Node 21+, current browsers).
Promise.withResolvers (ES2024)
Section titled “Promise.withResolvers (ES2024)”Gets the resolve/reject functions without the escaping-variable dance.
const { promise, resolve, reject } = Promise.withResolvers();socket.onmessage = (e) => resolve(e.data);socket.onerror = reject;await promise;String and number helpers
Section titled “String and number helpers”'a-b-c'.replaceAll('-', '_'); // ES2021' x '.trimStart();'5'.padStart(3, '0'); // => '005'Number.parseFloat === parseFloat; // => true (namespaced aliases)Number.MAX_SAFE_INTEGER;Math.trunc(-4.7); // => -4Numeric separators and exponentiation
Section titled “Numeric separators and exponentiation”const budget = 1_000_000;2 ** 10; // => 1024Intl — internationalization
Section titled “Intl — internationalization”Intl is a built-in namespace for locale-aware formatting. It removes the need for most date and number formatting libraries.
Numbers and currency
Section titled “Numbers and currency”new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD',}).format(1234.5); // => '$1,234.50'
new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR',}).format(1234.5); // => '1.234,50 €'
new Intl.NumberFormat('en-US', { notation: 'compact',}).format(1234567); // => '1.2M'
new Intl.NumberFormat('en-US', { style: 'percent', maximumFractionDigits: 1,}).format(0.1234); // => '12.3%'Dates and times
Section titled “Dates and times”new Intl.DateTimeFormat('en-GB', { dateStyle: 'long', timeStyle: 'short', timeZone: 'Europe/London',}).format(new Date('2026-08-09T12:00:00Z'));// => '9 August 2026 at 13:00'
// Shorthand on Datenew Date().toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' });Relative time
Section titled “Relative time”const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });rtf.format(-1, 'day'); // => 'yesterday'rtf.format(3, 'hour'); // => 'in 3 hours'Lists, plurals, and collation
Section titled “Lists, plurals, and collation”new Intl.ListFormat('en', { style: 'long', type: 'conjunction' }) .format(['a', 'b', 'c']); // => 'a, b, and c'
const pr = new Intl.PluralRules('en-US');pr.select(1); // => 'one'pr.select(2); // => 'other'
['ä', 'z', 'a'].sort(new Intl.Collator('de').compare); // => ['a', 'ä', 'z']Error handling patterns
Section titled “Error handling patterns”Always throw Error objects
Section titled “Always throw Error objects”throw new Error('Something failed'); // has a stack tracethrow 'Something failed'; // a string — no stack, poor toolingtry { risky();} catch (err) { console.error(err.name, err.message, err.stack);}Optional catch binding (ES2019) lets you omit the parameter when you do not need it:
try { JSON.parse(input); } catch { return null; }Custom error types
Section titled “Custom error types”class ValidationError extends Error { constructor(field, message) { super(message); this.name = 'ValidationError'; this.field = field; }}
class NotFoundError extends Error { constructor(resource) { super(`${resource} not found`); this.name = 'NotFoundError'; }}Branch on the type, not on message text:
try { await handleRequest();} catch (err) { if (err instanceof ValidationError) return respond(400, { field: err.field }); if (err instanceof NotFoundError) return respond(404); throw err; // unknown — do not swallow it}Error cause (ES2022)
Section titled “Error cause (ES2022)”Wrap a low-level error while preserving the original.
try { await db.query(sql);} catch (err) { throw new Error('Failed to load user profile', { cause: err });}catch (err) { console.error(err.message); // 'Failed to load user profile' console.error(err.cause.message); // the underlying database error}Catch narrowly
Section titled “Catch narrowly”// Bad — hides bugs in render()try { const data = await fetchData(); render(data);} catch (err) { console.error('fetch failed', err); // wrong: render() errors land here too}
// Betterlet data;try { data = await fetchData();} catch (err) { return showError(err);}render(data);Result-style returns for expected failures
Section titled “Result-style returns for expected failures”Exceptions are for exceptional conditions. When failure is a normal outcome — validation, parsing user input, a lookup miss — returning a value is clearer and impossible to forget.
function parseConfig(text) { try { return { ok: true, value: JSON.parse(text) }; } catch (err) { return { ok: false, error: err.message }; }}
const result = parseConfig(input);if (!result.ok) return showError(result.error);use(result.value);Validate at the boundary
Section titled “Validate at the boundary”Trust nothing that crosses into your code — network responses, localStorage, query strings, user input. Check it once at the edge, then rely on it internally.
async function getUser(id) { const res = await fetch(`/api/users/${id}`); if (!res.ok) throw new Error(`HTTP ${res.status} fetching user ${id}`);
const data = await res.json(); if (typeof data?.name !== 'string') { throw new TypeError('Malformed user response'); } return data;}Global safety nets
Section titled “Global safety nets”Last-resort handlers for logging, never for control flow.
// Browserwindow.addEventListener('error', (e) => report(e.error));window.addEventListener('unhandledrejection', (e) => report(e.reason));// Nodeprocess.on('unhandledRejection', (reason) => { console.error('Unhandled rejection:', reason); process.exit(1);});process.on('uncaughtException', (err) => { console.error('Uncaught exception:', err); process.exit(1); // the process state is unreliable — exit});Retry with backoff
Section titled “Retry with backoff”const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function retry(fn, { attempts = 3, baseMs = 200 } = {}) { let lastError; for (let i = 0; i < attempts; i++) { try { return await fn(); } catch (err) { lastError = err; if (i < attempts - 1) await sleep(baseMs * 2 ** i); } } throw new Error(`Failed after ${attempts} attempts`, { cause: lastError });}Key points
Section titled “Key points”- Floats cannot represent decimal fractions exactly. Use integer minor units for money and a tolerance for comparison; keep large IDs as strings or
BigInt. - Coercion bugs come from
+and==. Convert explicitly and compare with===. - The recurring bug list: shared object references,
sortmutating and string-sorting,asyncinforEach,||swallowing0,varin loops, lostthis,NaN !== NaN, zero-basedDatemonths. - Reach for
?.,??,??=, spread,structuredClone,.at(-1),Object.hasOwn, and theto*array methods — they replace whole categories of defensive code. Intlhandles currency, dates, relative time, lists, plurals, and collation; build formatters once and reuse them.- Throw
Errorsubclasses, branch oninstanceof, chain context with{ cause }, keeptryblocks narrow, never write an emptycatch. - Return result objects for expected failures; reserve exceptions for the unexpected. Validate at the boundary and log unhandled errors globally.