Modern Syntax, ES Modules &
The WeakMap Superpower.
You survived prototypes and this.
Now let's make your day-to-day JavaScript delightful. Optional chaining, nullish
coalescing, ES Modules, generators, WeakMap, BigInt, and every modern feature that turns
you from "struggling with JS" into "fluent in JS".
01 · The 2015–Present JavaScript Revolution
In Parts 1–3, you learned how JavaScript works: the event loop, async patterns, the
object model, and this. In Part 4, you learn how JavaScript feels in 2026
— the features that turned it from a language you endure into one you enjoy.
Between 2015 (ES6) and today, JavaScript gained roughly one major release per year. Each release added small quality-of-life features that, cumulatively, transformed the language. Most importantly for you as a backend developer, these features also reached Node.js — and Node 22+ now runs essentially the entire modern specification.
Fewer null checks
Optional chaining and nullish coalescing eliminate 90% of defensive code.
Real modules
ES Modules are now the standard. CommonJS is legacy. Migration is easier than you think.
Lazy everything
Dynamic imports, generators, top-level await — load only what you need, when you need it.
Memory that behaves
WeakMap and FinalizationRegistry give you garbage collection control that Java has had for years.
Numbers without lies
BigInt. Numeric separators. Real big-number arithmetic that doesn't drift.
Arrays that understand intent
.at(), .findLast(), Object.groupBy() — no more hand-rolled helpers.
02 · Optional Chaining ?.
Optional chaining is the single most impactful feature for day-to-day backend code. It replaces
the classic && guards that clutter every API response handler.
Before and After — The Common Case
Same result: if user or user.address is null or
undefined, the whole expression short-circuits to undefined. No
TypeError. But the modern form is dramatically cleaner, and — crucially — it
doesn't swallow falsy values like 0 or '' the way
&& does.
The && trap: user && user.address short-circuits
on ANY falsy value — including 0, '', false,
NaN. user?.address only short-circuits on null or
undefined. That's almost always what you actually want.
Three Forms You Need to Know
const response = {
status: 200,
data: {
user: { profile: { name: 'Alice' } },
fetchMeta: function () { return 'cached'; }
}
};
// 1. Property access — ?. → undefined if nullish
const name = response?.data?.user?.profile?.name;
// 'Alice' — walks the chain safely
const missing = response?.data?.missing?.deeply?.nested;
// undefined — short-circuits at 'missing'
// 2. Method call — ?.() → undefined if method doesn't exist
const meta = response?.data?.fetchMeta?.();
// 'cached'
const noMethod = response?.data?.nonExistent?.();
// undefined — does NOT throw TypeError
// 3. Bracket access — ?.[] → for dynamic keys
const key = 'status';
const value = response?.[key];
// 200
Interactive: Optional Chaining Explorer
Real Backend Pattern — Normalizing External API Responses
When consuming third-party APIs, the shape of the response is not under your control. Optional chaining lets you extract fields safely without defensive cruft:
async function fetchUserProfile(userId) {
const res = await fetch(`/api/users/${userId}`);
const body = await res.json();
// Extract deeply nested fields safely
return {
id: body?.data?.user?.id ?? null,
email: body?.data?.user?.email?.toLowerCase() ?? null,
name: body?.data?.user?.profile?.displayName ?? body?.data?.user?.email ?? 'Anonymous',
avatarUrl: body?.data?.user?.profile?.avatar?.url ?? '/default-avatar.png',
role: body?.data?.user?.roles?.[0] ?? 'user',
lastLoginAt: body?.data?.user?.activity?.lastLoginAt ?? null
};
}
Notice how each field has a fallback via ?? (nullish coalescing — next section).
Combined, these two operators are the workhorses of modern API integration.
The pattern that pays off: define a safeGet wrapper once and
reuse it everywhere:
const safe = (fn, fallback = null) => { try { return fn() ?? fallback; } catch { return fallback; } };
Then safe(() => res.data.user.profile.name, 'Anonymous') handles every edge
case in one line.
03 · Nullish Coalescing ??
?? returns the right-hand side only if the left is null or
undefined. That's it. It's the safe replacement for || in almost every
backend scenario.
Why || Is Often Wrong
The || operator treats all falsy values the same: 0,
'', false, NaN, null, undefined.
But for config and user data, you often want to allow 0 and '' as
valid values.
When Each Operator Is Right
| Scenario | Use || |
Use ?? |
|---|---|---|
| Default value when user provides nothing | — | ✅ |
| Allow 0, '', false as valid inputs | — | ✅ |
| Boolean coercion with fallback for any falsy | ✅ | — |
| Config with numeric defaults (0 valid) | — | ✅ |
| String defaults (empty string valid) | — | ✅ |
| Feature flags with explicit false | — | ✅ |
| "Show placeholder if not set" (any falsy OK) | ✅ | — |
Rule of thumb: default to ??. Use || only when
you genuinely want to reject every falsy value — and if you do, comment why.
Real Backend Code — Config Loading
function loadConfig(env = process.env) {
return {
// Ports: 0 means "any available port" — must be preserved!
port: Number(env.PORT ?? 3000),
// String defaults — empty string is a valid host meaning "any"
host: env.HOST ?? '0.0.0.0',
// Log level: empty string or "silent" both suppress logs
logLevel: env.LOG_LEVEL ?? 'info',
// Booleans: strings from env — explicit parsing
debugMode: env.DEBUG === 'true',
trustProxy: env.TRUST_PROXY === 'true',
// Timeouts: 0 means "no timeout" in many systems
requestTimeoutMs: Number(env.REQUEST_TIMEOUT_MS ?? 30_000),
// DB pool: 0 is a valid but unusual setting; allow it
dbPoolSize: Number(env.DB_POOL_SIZE ?? 10)
};
}
Combining ?. and ?? — The Ultimate Pattern
// Extract any deeply nested field with a fallback, in one line.
function extract(obj, path, fallback = null) {
const result = path.split('.').reduce((acc, key) => acc?.[key], obj);
return result ?? fallback;
}
const apiResponse = {
data: {
user: { name: 'Alice', prefs: { theme: '', language: null } }
}
};
extract(apiResponse, 'data.user.name'); // 'Alice'
extract(apiResponse, 'data.user.prefs.theme'); // '' (empty string preserved)
extract(apiResponse, 'data.user.prefs.language', 'en'); // 'en' (null → fallback)
extract(apiResponse, 'data.missing.deep.path', 'default'); // 'default'
04 · Logical Assignment ||=, &&=, ??=
Three operators that combine assignment with logical fallback. They're newer (ES2021) and dramatically reduce the "check-then-assign" boilerplate that littered older code.
The Three Operators — Each With a Purpose
// ??= — assign only if currently null or undefined
let config = null;
config ??= { debug: false };
console.log(config); // { debug: false }
config ??= { debug: true }; // no change — config is not nullish
console.log(config); // { debug: false }
// ||= — assign only if currently falsy (any falsy value)
let timeout = 0;
timeout ||= 5000;
console.log(timeout); // 5000 — 0 was falsy
let name = 'Alice';
name ||= 'Anonymous';
console.log(name); // 'Alice' — truthy, no change
// &&= — assign only if currently truthy
let user = { name: 'Bob', verified: true };
user.verified &&= user.name === 'Bob';
console.log(user.verified); // true (both truthy)
let empty = '';
empty &&= 'fallback';
console.log(empty); // '' (empty string is falsy → skip)
Real Backend Use — Smart Cache & Config Merging
// Lazy cache initialization — cleaner than "if (!cache[key])"
const cache = new Map();
function getCached(key, factory) {
if (!cache.has(key)) {
cache.set(key, factory());
}
return cache.get(key);
}
// Or, using nullish logical assignment for object-based cache:
const objCache = Object.create(null);
function getCachedObj(key, factory) {
objCache[key] ??= factory();
return objCache[key];
}
// Idempotent config initialization — perfect for env fallbacks
const config = {};
config.dbUrl ??= process.env.DB_URL;
config.dbUrl ??= 'postgres://localhost/dev';
// dbUrl is set to the first non-nullish value
// Middleware flags — only set if truthy
let options = { cors: true, rateLimit: 100 };
options.rateLimit &&= options.rateLimit > 0;
// If rateLimit is truthy AND > 0, keep it; otherwise set to false
Backend win: options.retries ??= 3 is exactly the intent
behind every "use default if not set" pattern. It replaces 3 lines of defensive code with 1,
and it doesn't accidentally treat 0 as "missing".
05 · ES Modules — The Real Module System
For the first 20 years of its life, JavaScript had no module system. Everything was global.
Then came CommonJS (require / module.exports), then AMD, then UMD.
Finally, in ES6, the language got native modules: import and
export.
For backend developers, this matters because Node.js now supports ESM natively
(since Node 14) and the ecosystem is migrating rapidly. If you're still writing
require(), you're using a legacy system that has real limitations.
The Six Import/Export Forms
// 1. Named export — the most common form
export function findById(id) { /* ... */ }
export function create(data) { /* ... */ }
export const MAX_PAGE_SIZE = 100;
// 2. Export at the bottom — common when refactoring
function update(id, data) { /* ... */ }
function deleteById(id) { /* ... */ }
export { update, deleteById };
// 3. Rename on export — public API differs from internal name
function internalValidate() { /* ... */ }
export { internalValidate as validate };
// 4. Default export — one main thing per module
export default class UserService { /* ... */ }
// 5. Re-export from another module (barrel pattern)
export { findById } from './queries.js';
export * from './validators.js';
// 6. Namespace export
export * as userApi from './api.js';
// Named imports
import { findById, create, MAX_PAGE_SIZE } from './user-service.js';
// Rename on import
import { internalValidate as validate } from './user-service.js';
// Default import (name can be anything)
import UserService from './user-service.js';
// Namespace import — everything grouped under one name
import * as userService from './user-service.js';
userService.findById(1);
// Combined default + named
import UserService, { findById, create } from './user-service.js';
// Node.js built-ins use node: prefix (recommended)
import fs from 'node:fs';
import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { randomUUID } from 'node:crypto';
CommonJS → ESM — The Full Cheat Sheet
| CommonJS | ESM |
|---|---|
const x = require('mod') | import x from 'mod' |
const { a, b } = require('mod') | import { a, b } from 'mod' |
module.exports = value | export default value |
module.exports = { a, b } | export { a, b } |
exports.a = a | export { a } or export const a = … |
require('./x') | import './x.js' (extension required!) |
__dirname | import.meta.dirname (Node 20.11+) or import.meta.url |
__filename | import.meta.filename (Node 20.11+) |
require.resolve() | import.meta.resolve() |
require.main === module | import.meta.main (Node 24+) |
Enabling ESM in Node.js — three options:
1. Set "type": "module" in package.json — every .js file is now ESM.
2. Use .mjs extension for ESM files alongside .cjs for CJS.
3. Use .js files with explicit .mjs/.cjs for edge cases.
Option 1 is the modern standard for new projects.
Interactive: Module Graph Visualizer
06 · Dynamic Import & Lazy Loading
Static import statements are evaluated before your code runs. That's
great for predictable dependencies but terrible for optional features, plugins, or heavy
modules that only some requests need.
Dynamic import — import('./module.js') — returns a promise that
resolves to the module. You can call it anywhere: inside functions, inside conditionals,
inside loops, based on runtime data.
// Static import — always loaded, always parsed
import { heavyCompression } from './compression.js';
// Dynamic import — loaded only when actually needed
async function processUpload(file, options) {
if (options.compress) {
// Only loads the compression module when compress is true
const { heavyCompression } = await import('./compression.js');
file = heavyCompression(file);
}
return upload(file);
}
// Plugin loading based on config
async function loadPlugins(pluginNames) {
const plugins = await Promise.all(
pluginNames.map((name) => import(`./plugins/${name}.js`))
);
return plugins.map((m) => m.default);
}
// Conditional polyfill loading
async function ensureCryptoSupport() {
if (!globalThis.crypto) {
await import('./polyfills/crypto.js');
}
}
The Startup Performance Win
In a large Node.js app, dynamic imports can cut cold-start time dramatically:
// ❌ Static imports — every route's deps load at startup
import adminController from './controllers/admin.js';
import reportController from './controllers/reports.js';
import analyticsController from './controllers/analytics.js';
import csvExporter from './exporters/csv.js';
// ... 40 more — total startup overhead: 1.2 seconds
// ✅ Dynamic imports — only loaded when a request actually hits them
app.get('/admin/users', async (req, res) => {
const adminController = (await import('./controllers/admin.js')).default;
await adminController.listUsers(req, res);
});
app.'/reports/monthly', async (req, res) => {
const reportController = (await import('./controllers/reports.js')).default;
await reportController.generate(req, res);
});
// Startup time drops by 80%+. Cold starts in serverless become fast.
// Modules are cached after first load, so subsequent calls are instant.
Don't overuse dynamic imports. They make code harder to trace, break static analysis tools, and prevent bundlers from tree-shaking. Use them for:
• Optional features (plugins, formats, export types).
• Heavy dependencies loaded conditionally.
• Serverless/lambda cold-start optimization.
• Not for every import "just in case".
07 · Top-Level Await
For most of JavaScript's history, await only worked inside async
functions. You needed to wrap your top-level code in an IIFE or .then() chain.
In ES modules, you can await at the top level directly.
// ❌ The old way — wrap everything in an async IIFE
(async () => {
const config = await loadConfig();
const server = await createServer(config);
server.listen(config.port);
})();
// ✅ Modern way — top-level await directly in ESM
import { loadConfig } from './config.js';
import { createServer } from './server.js';
const config = await loadConfig();
const server = await createServer(config);
server.listen(config.port);
console.log(`Listening on ${config.port}`);
Real Backend Pattern — Async Module Initialization
import pg from 'pg';
import { readFile } from 'node:fs/promises';
// Top-level await — pool is ready before anyone imports this module
const secrets = JSON.parse(await readFile('/run/secrets/db.json', 'utf8'));
export const pool = new pg.Pool({
host: secrets.host,
port: secrets.port,
user: secrets.user,
password: secrets.password,
database: secrets.database,
max: 20
});
// Warm up the pool — first request won't pay the connection cost
await pool.query('SELECT 1');
console.log('[db] pool ready');
Now any module that imports pool is guaranteed it's connected and warm. No more
"database not initialized" race conditions in startup.
Top-level await is not free. It blocks the module graph. If module A uses top-level await, every module that imports A must wait for A. This can hurt cold-start time in serverless. Use it for essential initialization (DB pools, config loading), not for optional data fetching.
08 · Generators in Production
Generators are functions that can pause and resume. They're the mechanism behind
async/await, and they're incredibly powerful for streaming, lazy
evaluation, and state machines.
function* numberStream() {
console.log('starting');
yield 1;
console.log('resumed');
yield 2;
console.log('resumed again');
yield 3;
return 'done';
}
const gen = numberStream();
gen.next(); // logs 'starting' → { value: 1, done: false }
gen.next(); // logs 'resumed' → { value: 2, done: false }
gen.next(); // logs 'resumed again' → { value: 3, done: false }
gen.next(); // { value: 'done', done: true }
gen.next(); // { value: undefined, done: true }
// Iteration is automatic with for...of
for (const n of numberStream()) {
console.log(`got ${n}`);
// Logs: starting, got 1, resumed, got 2, resumed again, got 3
}
Interactive: Infinite Generator Demo
The Killer Feature — Lazy Pipelines
Generators are lazy: nothing is computed until you ask for it. Combined with generators, you can process infinite streams or enormous datasets with O(1) memory.
// Composable generator utilities — the "lazy" versions of array methods
function* range(start, end, step = 1) {
for (let i = start; i < end; i += step) yield i;
}
function* lazyMap(iter, fn) {
for (const item of iter) yield fn(item);
}
function* lazyFilter(iter, pred) {
for (const item of iter) {
if (pred(item)) yield item;
}
}
function* take(iter, n) {
let count = 0;
for (const item of iter) {
if (count++ >= n) return;
yield item;
}
}
// Compose: first 5 even squares of 1..∞
const pipeline = take(
lazyFilter(
lazyMap(range(1, 1_000_000_000), (n) => n * n),
(n) => n % 2 === 0
),
5
);
console.log([...pipeline]); // [4, 16, 36, 64, 100]
// Notice: we iterated range 1..1_000_000_000 in our heads,
// but the generator only computed values up to where 'take' stopped.
// Memory: constant. Time: proportional to what was consumed.
Why this is a superpower: The array version range(1, 1e9).map(...).filter(...).slice(0,5)
would allocate a billion-element array, blow up memory, and crash the process. The generator
version uses constant memory and finishes in microseconds. This is how you
process log streams, database cursors, and CSV files.
Real Backend Pattern — Async Pagination Stream
// Async generator — combines the event loop, promises, and generators
async function* streamUsers(db, { pageSize = 100 } = {}) {
let cursor = null;
while (true) {
const { rows, nextCursor } = await db.queryUsers({ cursor, limit: pageSize });
for (const user of rows) yield user;
if (!nextCursor) break;
cursor = nextCursor;
}
}
// Consume — constant memory regardless of total users
async function sendWelcomeEmails(db, mailer) {
let sent = 0;
for await (const user of streamUsers(db, { pageSize: 50 })) {
if (user.welcomeSent) continue;
await mailer.send(user.email, 'welcome');
sent++;
if (sent % 100 === 0) console.log(`Sent ${sent}`);
}
return sent;
}
Bonus — State Machines with Generators
Generators are also ideal for step-by-step state machines — a pattern that appears in protocol implementations, retry workflows, and long-running processes:
function* deployWorkflow() {
const build = yield { step: 'build', description: 'Compiling source' };
if (build.failed) {
yield { step: 'notify-failure', description: 'Alerting team' };
return;
}
const test = yield { step: 'test', description: 'Running test suite' };
if (test.coverage < 80) {
yield { step: 'warn-coverage', description: 'Low coverage' };
}
yield { step: 'deploy', description: 'Pushing to production' };
yield { step: 'verify', description: 'Health checking' };
return { ok: true };
}
// Driver — the caller decides what to feed back in
const gen = deployWorkflow();
let result = gen.next();
while (!result.done) {
console.log(`Step: ${result.value.step} — ${result.value.description}`);
// Execute the step, then feed result back in
const stepResult = await executeStep(result.value);
result = gen.next(stepResult);
}
console.log(`Deployment done: ${JSON.stringify(result.value)}`);
09 · WeakMap, WeakSet, WeakRef — Memory That Behaves
In Part 3, you learned that objects are compared by reference and that this creates subtle bugs. Now we take the next step: WeakMap and WeakSet let you attach data to objects without preventing them from being garbage-collected.
The Three Weak Collections
| Collection | Keys/Values | Use Case |
|---|---|---|
WeakMap |
Object keys → any value | Attach metadata to objects without preventing GC |
WeakSet |
Objects only | Track "seen" objects, mark objects as processed |
WeakRef |
Holds a weak reference to one object | Advanced caching, finalization hooks |
The Classic Use Case — Private Data via WeakMap
// ES5-era pattern for private state — still used in old codebases
const privateData = new WeakMap();
class Counter {
constructor() {
privateData.set(this, { count: 0 });
}
increment() {
const state = privateData.get(this);
state.count++;
return state.count;
}
getCount() {
return privateData.get(this).count;
}
}
// Private data is unreachable from outside
const c = new Counter();
c.increment();
console.log(c.getCount()); // 1
console.log(privateData.get(c)); // only accessible inside the module
// Modern equivalent — #private fields (from Part 3)
class CounterModern {
#count = 0;
increment() { return ++this.#count; }
getCount() { return this.#count; }
}
// Same privacy, cleaner syntax, but WeakMap still useful for:
// - Attaching data to objects you don't control (e.g., DOM nodes, lib objects)
// - Cross-module private state without subclassing
Real Backend Use Case — Request Metadata
A classic pattern: attach request-scoped data to the request object without modifying it. WeakMap ensures the metadata is garbage-collected when the request ends.
// Attach request-scoped metadata without polluting the request object
const requestMeta = new WeakMap();
function withContext(middleware) {
return function (req, res, next) {
const context = {
requestId: crypto.randomUUID(),
startTime: Date.now(),
userId: null,
tags: {}
};
requestMeta.set(req, context);
return middleware(req, res, next);
};
}
// Anywhere in your app — retrieve context by reference
function getContext(req) {
return requestMeta.get(req);
}
function logger(req) {
const ctx = getContext(req);
return {
info(msg, meta) { console.log(`[${ctx.requestId}]`, msg, meta); },
error(msg, meta) { console.error(`[${ctx.requestId}]`, msg, meta); }
};
}
// When the response is sent and the request object is released,
// the metadata is garbage-collected automatically.
// No memory leak from long-lived processes handling millions of requests.
The Massive Win — No Memory Leak
In a server processing millions of requests, this distinction is the difference between a stable service and one that dies with "JavaScript heap out of memory" every few days.
WeakSet — Object Membership Tracking
// Track which objects have been processed — no memory leak
const processed = new WeakSet();
function processOnce(obj) {
if (processed.has(obj)) return;
processed.add(obj);
// Do the expensive work
console.log('Processing:', obj.id);
}
// Use case: avoiding cycles in graph traversal
function walkGraph(node, seen = new WeakSet()) {
if (seen.has(node)) return;
seen.add(node);
for (const child of node.children ?? []) {
walkGraph(child, seen);
}
visit(node);
}
WeakRef & FinalizationRegistry — Advanced Territory
These are advanced tools you should rarely need. But knowing they exist helps you understand the boundaries of JavaScript's memory model.
// WeakRef — hold a reference that doesn't prevent GC
let cached = new WeakRef(loadHeavyObject());
function getCached() {
const obj = cached.deref(); // may return undefined if GC collected it
if (obj) return obj;
// Object was collected — reload
const fresh = loadHeavyObject();
cached = new WeakRef(fresh);
return fresh;
}
// FinalizationRegistry — run code when an object is garbage-collected
const registry = new FinalizationRegistry((heldValue) => {
console.log(`Object with id ${heldValue} was garbage collected`);
});
function createTrackedResource(id) {
const resource = { id, cleanup: true };
registry.register(resource, id, resource);
return resource;
}
// ⚠️ FinalizationRegistry is NON-DETERMINISTIC.
// It's for observability, NOT for critical cleanup.
// Use try/finally and AbortController for guaranteed cleanup.
Don't use WeakRef for normal caching. Garbage collection timing is
unpredictable — your "cache" may lose entries at any moment, and the process may never
collect anything under memory pressure. Use an LRU cache with a size limit for real caching
needs (like lru-cache).
10 · BigInt & Numeric Precision
JavaScript numbers are IEEE-754 doubles. They can represent integers exactly up to 253 − 1 (about 9 quadrillion). Beyond that, you get silent precision loss. That's fine for most business logic, but disastrous for things like financial IDs, Twitter snowflakes, big hashes, and cryptocurrency.
// 💥 The classic precision loss
console.log(9007199254740993); // 9007199254740992 😱 (last digit lost!)
console.log(0.1 + 0.2); // 0.30000000000000004
// ✅ BigInt for precise integer arithmetic
const big = 9007199254740993n; // note the 'n' suffix
console.log(big); // 9007199254740993n ✅
const max = BigInt.MAX_SAFE_INTEGER;
console.log(typeof big); // 'bigint'
console.log(big + 1n); // 9007199254740994n
// Conversions
BigInt('9007199254740993'); // 9007199254740993n
Number(9007199254740993n); // 9007199254740992 (lossy!)
// ⚠️ Cannot mix BigInt and Number in arithmetic
// console.log(1n + 1); // TypeError: Cannot mix BigInt and other types
console.log(1n + 1n); // 2n — must use BigInt literals
// Division truncates (no fractions)
console.log(7n / 2n); // 3n
Real Backend Use — Snowflake IDs
Twitter/X-style snowflake IDs are 64-bit integers. JavaScript number cannot
represent them exactly — a common bug when consuming Twitter API or Discord API. Use
BigInt or store IDs as strings.
// Snowflake structure: 41 bits timestamp + 10 bits machine + 12 bits sequence
const EPOCH = 1288834974657n; // Twitter epoch
function extractTimestamp(snowflake) {
const id = BigInt(snowflake);
const timestamp = (id >> 22n) + EPOCH;
return new Date(Number(timestamp));
}
function extractMachine(snowflake) {
const id = BigInt(snowflake);
return Number((id >> 12n) & 0x3FFn);
}
function extractSequence(snowflake) {
const id = BigInt(snowflake);
return Number(id & 0xFFFn);
}
// Usage
const tweetId = '1234567890123456789';
console.log(extractTimestamp(tweetId)); // tweet creation date
console.log(extractMachine(tweetId)); // 0-1023
console.log(extractSequence(tweetId)); // 0-4095
// Important: keep IDs as strings in JSON to preserve precision
// JSON.stringify({ id: 1234567890123456789n }) throws TypeError
const payload = { id: tweetId.toString() }; // store as string
Numeric Separators — Readability Boost
// Underscore separators — visual grouping, ignored by the engine
const a = 1_000_000; // 1000000
const b = 1_000_000_000; // 1000000000
const c = 0xFF_FF_FF; // 16777215
const d = 0b1010_0001; // 161
const e = 1_000_000n; // 1000000n (BigInt)
// Great for time and byte constants
const MS_PER_SECOND = 1_000;
const MS_PER_MINUTE = 60_000;
const MS_PER_HOUR = 3_600_000;
const MS_PER_DAY = 86_400_000;
const MAX_FILE_SIZE = 10_485_760; // 10 MB in bytes
11 · Modern Array & String Methods
Every year, JavaScript gains a few more array and string methods that replace hand-rolled helpers. Here are the ones you should actually use in 2026.
Array.at() — The End of arr[arr.length - 1]
.at() accepts negative indices, giving Python-like behavior for the "Nth from end"
access pattern. Works on strings, arrays, and typed arrays.
findLast & findLastIndex
const logs = [
{ level: 'info', msg: 'started' },
{ level: 'error', msg: 'db timeout' },
{ level: 'info', msg: 'retry' },
{ level: 'error', msg: 'queue full' },
{ level: 'info', msg: 'completed' }
];
// Find the LAST error (most recent in chronological order)
const lastError = logs.findLast((l) => l.level === 'error');
// { level: 'error', msg: 'queue full' }
const lastErrorIdx = logs.findLastIndex((l) => l.level === 'error');
// 3
Object.groupBy & Map.groupBy — Data Shaping Made Easy
const orders = [
{ id: 1, status: 'pending', amount: 50 },
{ id: 2, status: 'paid', amount: 100 },
{ id: 3, status: 'pending', amount: 75 },
{ id: 4, status: 'paid', amount: 200 },
{ id: 5, status: 'shipped', amount: 30 }
];
// Object.groupBy — returns plain object keyed by callback result
const byStatus = Object.groupBy(orders, (o) => o.status);
// {
// pending: [{ id: 1, ... }, { id: 3, ... }],
// paid: [{ id: 2, ... }, { id: 4, ... }],
// shipped: [{ id: 5, ... }]
// }
// Compute totals per status
const totals = {};
for (const [status, items] of Object.entries(byStatus)) {
totals[status] = items.reduce((sum, o) => sum + o.amount, 0);
}
// { pending: 125, paid: 300, shipped: 30 }
// Map.groupBy — when you need object keys (not just strings)
const byDate = Map.groupBy(events, (e) => e.date);
String.replaceAll, matchAll, and Other Gems
const template = 'Hello {{name}}, your order {{orderId}} is ready.';
// replaceAll — replaces EVERY occurrence (no more /g regex)
const rendered = template
.replaceAll('{{name}}', 'Alice')
.replaceAll('{{orderId}}', '#12345');
// 'Hello Alice, your order #12345 is ready.'
// matchAll — returns iterator of all matches (not just first)
const log = '[2026-01-01] INFO ok [2026-01-02] WARN slow [2026-01-03] ERROR fail';
const pattern = /\[(\d{4}-\d{2}-\d{2})\]\s+(\w+)/g;
for (const match of log.matchAll(pattern)) {
const [, date, level] = match;
console.log(`${date} → ${level}`);
}
// String.padStart / padEnd — useful for formatting
const id = 42;
id.toString().padStart(6, '0'); // '000042'
'file'.padEnd(10, '.'); // 'file......'
// String.trimStart / trimEnd — trim one side only
' hello '.trimStart(); // 'hello '
' hello '.trimEnd(); // ' hello'
// String.includes, startsWith, endsWith — modern search
'user@example.com'.includes('@'); // true
'Bearer token123'.startsWith('Bearer '); // true
'file.tar.gz'.endsWith('.gz'); // true
More Useful Additions
| Method | What It Does | Backend Use Case |
|---|---|---|
Array.prototype.flat() |
Flattens nested arrays | Merging results from multiple sources |
Array.prototype.flatMap() |
Map + flatten in one pass | Transforming 1-to-many relationships |
Array.prototype.includes() |
Boolean membership check | Permission checks, feature gates |
Object.entries() |
Array of [key, value] pairs | Iterating config objects |
Object.fromEntries() |
Build object from key-value pairs | Array transformations that preserve keys |
structuredClone() |
True deep clone with cycles | Snapshotting state before mutation |
crypto.randomUUID() |
RFC 4122 UUID v4 | Request IDs, correlation IDs, primary keys |
12 · Error.cause & Structured Errors
Before ES2022, when you caught an error and re-threw a higher-level one, you lost the original
stack trace. You had to invent your own convention (err.originalError = …).
Error.cause fixed this properly.
class DatabaseError extends Error {
constructor(message, cause) {
super(message, { cause });
this.name = 'DatabaseError';
}
}
class ServiceError extends Error {
constructor(message, cause) {
super(message, { cause });
this.name = 'ServiceError';
}
}
async function loadUserProfile(userId) {
try {
return await db.query('SELECT * FROM users WHERE id = ?', [userId]);
} catch (err) {
// Preserve the original error as the cause
throw new DatabaseError('Failed to load user profile', err);
}
}
async function getUserDashboard(userId) {
try {
return await loadUserProfile(userId);
} catch (err) {
throw new ServiceError('Could not build dashboard', err);
}
}
// Full error chain is preserved
try {
await getUserDashboard(42);
} catch (err) {
console.log(err.message); // 'Could not build dashboard'
console.log(err.cause.message); // 'Failed to load user profile'
console.log(err.cause.cause.message); // the original DB error
// Walk the chain
let current = err;
while (current) {
console.log(`${current.name}: ${current.message}`);
current = current.cause;
}
}
AggregateError — Multiple Errors at Once
// AggregateError — thrown by Promise.any when all fail
try {
await Promise.any([
fetch('https://a.example.com'),
fetch('https://b.example.com'),
fetch('https://c.example.com')
]);
} catch (err) {
if (err instanceof AggregateError) {
console.log(`All ${err.errors.length} sources failed:`);
err.errors.forEach((e, i) => console.log(` [${i}] ${e.message}`));
}
}
// Creating your own AggregateError
function validateBatch(items) {
const errors = [];
items.forEach((item, i) => {
if (!item.name) errors.push(new Error(`Item ${i}: name missing`));
if (!item.email) errors.push(new Error(`Item ${i}: email missing`));
});
if (errors.length > 0) {
throw new AggregateError(errors, 'Batch validation failed');
}
}
13 · Production: Cache, DI, Event Bus
Let's combine everything into three production patterns that use the modern syntax you just learned. Each is real code you can drop into a Node.js service today.
Pattern 1 — TTL Cache with WeakMap Metadata
export class TtlCache {
#store = new Map();
#meta = new WeakMap();
#defaultTtlMs;
#maxSize;
constructor({ defaultTtlMs = 60_000, maxSize = 1000 } = {}) {
this.#defaultTtlMs = defaultTtlMs;
this.#maxSize = maxSize;
}
set(key, value, ttlMs = this.#defaultTtlMs) {
if (this.#store.size >= this.#maxSize && !this.#store.has(key)) {
const oldestKey = this.#store.keys().next().value;
this.delete(oldestKey);
}
this.#store.set(key, value);
const expiresAt = Date.now() + ttlMs;
this.#meta.set(value, { expiresAt }); // weak ref to value
return this;
}
get(key) {
const value = this.#store.get(key);
if (value === undefined) return undefined;
const meta = this.#meta.get(value);
if (meta && Date.now() > meta.expiresAt) {
this.delete(key);
return undefined;
}
return value;
}
async getOrLoad(key, loader, ttlMs) {
const cached = this.get(key);
if (cached !== undefined) return cached;
const loaded = await loader();
this.set(key, loaded, ttlMs);
return loaded;
}
delete(key) {
this.#store.delete(key);
return this;
}
clear() { this.#store.clear(); }
get size() { return this.#store.size; }
}
// Usage
const cache = new TtlCache({ defaultTtlMs: 30_000, maxSize: 500 });
const user = await cache.getOrLoad(
`user:${userId}`,
() => db.findUser(userId),
60_000
);
Pattern 2 — Dependency Injection via Map
export class Container {
#factories = new Map();
#singletons = new Map();
register(name, factory) {
this.#factories.set(name, factory);
return this;
}
singleton(name, factory) {
this.#factories.set(name, () => {
if (!this.#singletons.has(name)) {
this.#singletons.set(name, factory(this));
}
return this.#singletons.get(name);
});
return this;
}
resolve(name) {
const factory = this.#factories.get(name);
if (!factory) throw new Error(`No provider for ${name}`);
return factory(this);
}
}
// Wire it up
const container = new Container();
container
.singleton('db', () => new DbPool(config.dbUrl))
.singleton('userRepo', (c) => new UserRepo(c.resolve('db')))
.singleton('userService', (c) => new UserService(c.resolve('userRepo')))
.register('userController', (c) => new UserController(c.resolve('userService')));
const controller = container.resolve('userController');
Pattern 3 — Type-Safe Event Bus
export class EventBus {
#listeners = new Map();
#onceListeners = new WeakMap(); // per-listener metadata
on(event, handler) {
if (!this.#listeners.has(event)) {
this.#listeners.set(event, new Set());
}
this.#listeners.get(event).add(handler);
// Return an unsubscribe function — modern idiom
return () => this.off(event, handler);
}
once(event, handler) {
const wrapper = async (...args) => {
this.off(event, wrapper);
await handler(...args);
};
return this.on(event, wrapper);
}
off(event, handler) {
this.#listeners.get(event)?.delete(handler);
if (this.#listeners.get(event)?.size === 0) {
this.#listeners.delete(event);
}
return this;
}
async emit(event, payload) {
const handlers = this.#listeners.get(event);
if (!handlers) return [];
const results = await Promise.allSettled(
[...handlers].map((h) => h(payload))
);
// Log any handler failures without breaking others
results.forEach((r, i) => {
if (r.status === 'rejected') {
console.error(`Event handler #${i} for "${event}" failed:`, r.reason);
}
});
return results;
}
clear() { this.#listeners.clear(); }
}
// Usage
const bus = new EventBus();
const unsubscribe = bus.on('user.registered', async ({ userId, email }) => {
await sendWelcomeEmail(email);
});
bus.on('user.registered', async ({ userId }) => {
await provisionDefaults(userId);
});
await bus.emit('user.registered', { userId: 42, email: 'a@b.com' });
14 · AI Corner: CJS→ESM Migration & Modern Refactoring
AI shines brightest at large-scale mechanical migrations. Modernizing a legacy CommonJS codebase to ESM is exactly the kind of tedious, error-prone task where AI saves days.
CJS → ESM Conversion
"Convert this CommonJS file to ESM. Handle default export interop correctly. Show import.meta equivalents for __dirname."
Optional Chaining Audit
"Refactor every `a && a.b && a.b.c` pattern in this file to optional chaining. Do not change behavior for numeric or string falsy values."
Dynamic Import Conversion
"Convert the top 5 heaviest static imports in this file to dynamic imports. Explain which routes benefit most."
Memory Leak Detection
"Find all Map/Set usages in this code that should be WeakMap/WeakSet. Explain each conversion."
BigInt Migration
"Identify every place where a large integer could lose precision. Show BigInt conversions with JSON serialization handling."
Generator Extraction
"Convert this array-based loop into an async generator that streams results. Preserve error semantics."
The best migration prompt I've seen: "Migrate this Node.js service from CommonJS to ESM. Show me a before/after diff for each file. List every edge case: __dirname, __filename, require.main, JSON imports, native module interop. Then walk through the package.json changes needed for a hybrid transition." You get a migration plan in 30 seconds that would take 3 hours to research manually.
What AI gets wrong in ESM migrations: it often forgets file extensions in
relative imports ('./x' must become './x.js' in ESM), uses
require() to load JSON without checking for import assertions, and mixes
module.exports with export default. Always test on a branch first.
15 · Interactive Knowledge Check
Twelve questions covering every modern feature in this article. Each maps to a production scenario you'll hit within your first week of writing modern JavaScript.
Part 4 — Modern Syntax & Modules Quiz
Twelve questions. No guessing.16 · Cheat Sheet & What's Next
Modern Syntax — One-Page Summary
| Feature | One-Line Rule |
|---|---|
?. | Short-circuits on null/undefined only — not on 0/''. |
?.[] | Same for dynamic keys. |
?.() | Optional method call — no TypeError if missing. |
?? | Fallback only for nullish. Prefer over || for config. |
??= | Assign if currently nullish. Perfect for lazy init. |
||= | Assign if currently falsy (any falsy value). |
&&= | Assign only if currently truthy. |
import | Static, hoisted, resolved at parse time. |
import() | Dynamic, returns a promise. For optional/lazy deps. |
| Top-level await | Only in ESM. Use for critical init, not optional data. |
| Generators | Lazy sequences. Constant memory. Async generators for streaming. |
| WeakMap | Object keys. GC-friendly. Use for metadata and private state. |
| WeakSet | Track object membership without preventing GC. |
| WeakRef | Advanced caching only. Non-deterministic. Rarely needed. |
| BigInt | Precise integers beyond 253. Can't mix with Number. |
| Numeric separators | 1_000_000 — for readability. |
.at() | Negative indices work: arr.at(-1). |
findLast | Search from the end without reversing. |
Object.groupBy | Group arrays into objects — replaces reduce boilerplate. |
Error.cause | Preserve error chains with new Error(msg, { cause }). |
AggregateError | Multiple errors bundled — used by Promise.any. |
Do / Don't — Modern JavaScript Edition
✅ DO
- Default to
??over||for defaults. - Use
?.to eliminate defensive&&chains. - Enable ESM in new projects with
"type": "module". - Use dynamic imports for optional heavy dependencies.
- Reach for generators when processing large datasets.
- Attach request metadata with WeakMap — no leaks.
- Store BigInt IDs as strings in JSON.
- Preserve error chains with
{ cause }. - Use numeric separators for readability.
- Prefer
.at(-1),.findLast(),Object.groupBy().
❌ DON'T
- Don't use
||for defaults where0/''are valid. - Don't mix
require()andimportin the same file. - Don't omit
.jsextensions in ESM imports. - Don't overuse dynamic imports — bundlers get confused.
- Don't put top-level await in every module.
- Don't use a regular Map for object-keyed metadata (leaks).
- Don't use WeakRef for normal caching.
- Don't mix BigInt and Number in arithmetic.
- Don't lose error context by re-throwing without cause.
- Don't fight modern syntax — embrace it.
What's Coming in Part 5
Part 5 is about the browser and DOM — the features that matter when your JavaScript has to actually run in a page:
- The DOM tree and the rendering pipeline — layout, paint, composite.
- Event phases: capture, target, bubble — and how to use them well.
- Event delegation patterns (and when they hurt).
- MutationObserver, IntersectionObserver, ResizeObserver.
- requestAnimationFrame and the 60-fps budget.
- Memory leaks in SPAs and how to hunt them with DevTools.
- The Performance API — measuring what actually matters.
- AI-assisted performance audits.
Practice before Part 5: migrate one small module in your codebase from
CommonJS to ESM. Then convert one long synchronous loop into a generator pipeline. Then find
one Map that should be a WeakMap. Each exercise is small — 15 minutes
each — but they'll all become muscle memory you carry for the rest of your career.
Part 4 of 7 · JavaScript for Backend Developers · FreeLearning365.com
🌟 Continue Learning on FreeLearning365
Free tools, tutorials, and question banks for developers, students, and professionals.
- Learn Free ProgrammingJavaScript, Angular, Python, SQL, Data Analysis & More
- 100+ Free Online ToolsDevelopers, SEO Specialists & Daily Tasks
- Professional IT TrainingAdvance Your Career with Hands-On Courses
- AI Prompt Generator40+ Professional Prompt Types
- Drag & Drop Form GeneratorBootstrap 5.3/4, Custom CSS, Grid Layout
- Income Tax CalculatorNBR Slabs, Rebate & Minimum Tax
- NPS 2026 Salary Calculatorবাংলাদেশ জাতীয় বেতন স্কেল ২০২৬
- Electricity Bill CalculatorBERC Tariff & Appliance Report
- eBook CollectionFree for Download
- BCS / HSC / SSC Question Bankবাংলাদেশের সর্ববৃহৎ ফ্রি প্রশ্ন ব্যাংক
- AI Background RemoverRemove Image Background Free
- Free QR Code GeneratorCreate Custom QR Codes Online
- Barcode & Label GeneratorCustom Barcodes, QR Codes, A4 Sheets
- EV Class 9-10 All SubjectsPhysics, Chemistry, Biology, Math, ICT & BGS
- Our ServicesFull IT Solutions & Training
- Job Interview PreparationProgramming, Cloud, Data, ERP & More

0 Comments
thanks for your comments!