Skip to content

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.

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.30000000000000004
0.1 + 0.2 === 0.3; // => false
0.3 - 0.1; // => 0.19999999999999998
1.005 * 100; // => 100.49999999999999
Math.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); // => 160
const 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); // => true

Large integers lose precision above 2^53 − 1:

9007199254740993; // => 9007199254740992 ← the literal is already wrong
Number.isSafeInteger(2 ** 53); // => false
9007199254740993n; // => exact, as a BigInt

Rounding 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)
'5' + 3; // => '53' (+ concatenates if either side is a string)
'5' - 3; // => 2
[] + []; // => ''
[] + {}; // => '[object Object]'
[1, 2] + [3]; // => '1,23'
'5' * '2'; // => 10
true + true; // => 2
[] == false; // => true
null >= 0; // => true, but null > 0 is false and null == 0 is false

The reliable defenses:

  • Compare with ===, never == (except x == 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 predictably

The 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; // => 3

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 everyone
function makeConfig(overrides) {
return { ...defaults, ...overrides }; // new object each time
}

Spread is shallow, so tags is still shared. Use structuredClone(defaults) when nested state matters.

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-mutating
items.forEach(async (item) => { await save(item); });
console.log('done'); // runs immediately — nothing has been saved
for (const item of items) await save(item); // sequential
await Promise.all(items.map((item) => save(item))); // parallel
function setVolume(v) {
const volume = v || 50; // muting with v = 0 silently jumps to 50
const better = v ?? 50; // 0 stays 0
}
for (var i = 0; i < 3; i++) setTimeout(() => console.log(i)); // => 3, 3, 3
for (let i = 0; i < 3; i++) setTimeout(() => console.log(i)); // => 0, 1, 2
const timer = { count: 0, tick() { this.count++; } };
setInterval(timer.tick, 1000); // this is not `timer`
setInterval(() => timer.tick(), 1000); // fixed
typeof null; // => 'object'
typeof []; // => 'object'
Array.isArray([]); // => true
value === null; // the correct null check
NaN === NaN; // => false
[1, NaN].indexOf(NaN); // => -1
[1, NaN].includes(NaN); // => true
Number.isNaN(NaN); // => true (never use the global isNaN)
{ a: 1 } === { a: 1 }; // => false — identity, not structure
[1, 2].toString() === [1, 2].toString(); // => true, but fragile
new Date(2026, 0, 15); // => 15 January 2026 — month 0 is January
new Date(2026, 12, 1); // => 1 January 2027 — it overflows silently

Parsing 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.

parseInt('08'); // => 8 (safe in modern engines, but always pass a radix)
parseInt('0x10', 10); // => 0 — stops at 'x'
parseInt('12abc', 10); // => 12 — lenient
Number('12abc'); // => NaN — strict
Number(''); // => 0 — surprising
Number(null); // => 0
Number(undefined); // => NaN
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]
if (x = 5) { } // always truthy — assigns, does not compare
if (x === 5) { }
user.address.city; // TypeError if address is missing
user.address?.city ?? 'unknown'; // => 'unknown'

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 defined
const first = list?.[0];
options.retries ??= 3; // only if null/undefined
cache.items ||= []; // only if falsy
flags.debug &&= isDev; // only if already truthy
const merged = { ...defaults, ...userOptions };
const [head, ...tail] = list;
const { id, ...rest } = record;
const max = Math.max(...numbers);
const unique = [...new Set(values)];
const deep = structuredClone(state); // real deep copy: cycles, Date, Map, Set

Available in modern browsers and Node 17+. Throws on functions and symbols; class instances come back as plain objects.

const list = [1, 2, 3];
list.at(-1); // => 3
list.at(-2); // => 2
'hello'.at(-1); // => 'o'
Object.hasOwn(obj, 'key'); // safe even for Object.create(null) objects
arr.toSorted(cmp); arr.toReversed();
arr.toSpliced(1, 2); arr.with(0, 'new');
arr.findLast(fn); arr.findLastIndex(fn);
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).

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;
'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); // => -4
const budget = 1_000_000;
2 ** 10; // => 1024

Intl is a built-in namespace for locale-aware formatting. It removes the need for most date and number formatting libraries.

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%'
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 Date
new Date().toLocaleDateString('en-US', { weekday: 'long', month: 'short', day: 'numeric' });
const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' });
rtf.format(-1, 'day'); // => 'yesterday'
rtf.format(3, 'hour'); // => 'in 3 hours'
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']
throw new Error('Something failed'); // has a stack trace
throw 'Something failed'; // a string — no stack, poor tooling
try {
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; }
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
}

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
}
// 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
}
// Better
let 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);

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;
}

Last-resort handlers for logging, never for control flow.

// Browser
window.addEventListener('error', (e) => report(e.error));
window.addEventListener('unhandledrejection', (e) => report(e.reason));
// Node
process.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
});
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 });
}
  • 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, sort mutating and string-sorting, async in forEach, || swallowing 0, var in loops, lost this, NaN !== NaN, zero-based Date months.
  • Reach for ?., ??, ??=, spread, structuredClone, .at(-1), Object.hasOwn, and the to* array methods — they replace whole categories of defensive code.
  • Intl handles currency, dates, relative time, lists, plurals, and collation; build formatters once and reuse them.
  • Throw Error subclasses, branch on instanceof, chain context with { cause }, keep try blocks narrow, never write an empty catch.
  • Return result objects for expected failures; reserve exceptions for the unexpected. Validate at the boundary and log unhandled errors globally.