Skip to main content

Modern JavaScript (ES6+)

JavaScript has evolved rapidly since ES6 (2015). Each year brings new features that make the language more expressive and developer-friendly. This chapter covers the essential modern features you should know. ES6 was the biggest single upgrade in JavaScript’s history — it transformed the language from something many developers tolerated into something they genuinely enjoy writing. The features in this chapter are not optional nice-to-haves; they are the vocabulary of modern JavaScript. You will encounter destructuring, arrow functions, modules, and template literals in every codebase, tutorial, and job interview. Understanding them is table stakes.

1. Destructuring

Extract values from arrays and objects into distinct variables. Think of destructuring like unpacking a suitcase: instead of pulling items out one by one (const name = person.name; const age = person.age;), you declare a pattern that matches the shape of the data and JavaScript fills in the variables for you.

Array Destructuring

Object Destructuring

Function Parameters

Common destructuring gotcha: Forgetting the = {} default on function parameters. If someone calls your function with no arguments, undefined gets destructured, which throws TypeError: Cannot destructure property 'name' of undefined. Always add = {} when the entire parameter object is optional.

Destructuring Edge Cases


2. Spread Operator

Expand iterables (arrays, strings) or objects.

Arrays

Objects (ES2018)

Spread is shallow. This is the single most common source of bugs with spread/copy patterns. If your object contains nested objects or arrays, the spread creates new top-level properties but the nested values still point to the same references. Modifying a nested property in the copy modifies the original too. Use structuredClone() for deep copies.

3. Template Literals

Enhanced string formatting with backticks. Before template literals, building strings with variables required awkward concatenation: 'Hello, ' + name + '! You are ' + age + ' years old.' Template literals make this natural and readable.

4. Modules (ES6)

Organize code into reusable, isolated files. Before ES6 modules, JavaScript had no built-in module system — the community invented CommonJS (require/module.exports used in Node.js) and AMD. ES6 modules (import/export) are now the standard, supported natively in all modern browsers and Node.js.

Named Exports

Default Exports

Named vs Default exports — which to prefer? Named exports are generally better for maintainability: they enforce consistent names across your codebase, enable better IDE auto-imports, and make it obvious what a module provides. Default exports make sense for modules that export a single primary thing (a React component, a class). Many style guides (including the Airbnb style guide) prefer named exports.

ES Modules vs CommonJS — Complete Comparison

Dynamic Imports

Load modules on demand (code splitting). Unlike static import at the top of a file, dynamic import() returns a Promise and can be called anywhere — inside functions, inside conditionals, in response to user actions. This is how modern bundlers (Webpack, Vite) achieve code splitting.

5. Classes (ES6+)

Syntactic sugar over prototypes with additional features.

6. New Data Structures

Map (Key-Value, Any Type Keys)

Unlike plain objects (which only support string/Symbol keys), Map supports keys of any type — objects, functions, numbers, even NaN.

Map vs Plain Object — Complete Comparison

Set (Unique Values)

A collection that automatically deduplicates. Every value can only appear once.

Set vs Array — When to Use Which

WeakMap and WeakSet

Keys are weakly held — if there are no other references to the key object, it can be garbage collected and the entry is automatically removed. This prevents memory leaks when associating metadata with objects.

Map vs WeakMap — When to Use Which


7. New Array & Object Methods

Array Methods (ES2019+)

Mutating vs Non-Mutating Array Methods

This is one of the most common bug sources in JavaScript. Know which methods change the original array.

Object Methods (ES2017+)


8. Other Modern Features

Optional Chaining & Nullish Coalescing

Logical Assignment (ES2021)

These combine logical operators with assignment — a concise way to set defaults or update values conditionally.

Numeric Separators (ES2021)

String Methods (ES2017+)


Summary

Modern JavaScript is expressive, concise, and powerful:
  • Destructuring: Extract values from objects/arrays elegantly.
  • Spread/Rest: Combine, copy, and collect elements.
  • Modules: Organize code with import/export.
  • Classes: Clean OOP syntax with private fields.
  • Map/Set: Powerful data structures beyond objects/arrays.
  • Optional Chaining: Safe property access with ?..
Next, we’ll learn about DOM & Browser APIs, how JavaScript interacts with web pages.

Interview Deep-Dive

Strong Answer:
  • A shallow copy duplicates the top-level properties of an object, but nested objects and arrays are shared by reference. A deep copy recursively duplicates everything, so no references are shared between the original and the copy.
  • Shallow copy methods: (1) Spread operator: {...obj} or [...arr]. Clean syntax, widely used. (2) Object.assign({}, obj). Equivalent to spread for objects. (3) Array.from(arr) or arr.slice() for arrays.
  • Deep copy methods: (1) structuredClone(obj) (the modern answer, available in browsers and Node 17+). Handles circular references, Date, RegExp, Map, Set, ArrayBuffer, and more. Does NOT copy functions, DOM nodes, or prototype chains. (2) JSON.parse(JSON.stringify(obj)) (the legacy hack). Loses undefined values (they are stripped), converts Date objects to strings, chokes on circular references (throws), ignores Map, Set, Symbol keys, and functions. (3) Lodash _.cloneDeep. Handles almost everything, including functions and custom classes. The trade-off is a dependency.
  • The bug this question really tests: const copy = {...original}; copy.address.city = 'NYC'; — this also mutates original.address.city because address is a nested object that was not cloned. This is the number one source of state mutation bugs in Redux stores and React state updates. The fix is either structuredClone or manual nested spreading: {...original, address: {...original.address, city: 'NYC'}}.
  • Production recommendation: use structuredClone for general-purpose deep cloning. Use spread for shallow copies when you know the object is flat. Use Immer (a library) in Redux/React contexts for ergonomic immutable updates without manual deep spreading.
Follow-up: What does structuredClone NOT copy, and what happens if you try?structuredClone uses the structured clone algorithm (the same one used by postMessage and IndexedDB). It cannot clone: functions (throws DataCloneError), DOM nodes (throws), symbols (throws), property descriptors (getters/setters are invoked and their return values are cloned as plain values), the prototype chain (the clone is always a plain object, not an instance of a custom class), and WeakMap/WeakSet (throws). If your object contains any of these, you need a custom clone function or a library. The prototype chain limitation is particularly sneaky: if you structuredClone(new MyClass(...)), the result is a plain Object, not an instance of MyClass. instanceof MyClass returns false on the clone.
Strong Answer:
  • CommonJS (require/module.exports): the original Node.js module system. Modules are loaded synchronously. require() can be called anywhere (inside conditionals, inside functions). Exports are live bindings to a cached object — once a module is loaded, subsequent require() calls return the cached export.
  • ES Modules (import/export): the language-standard module system (ES6). Imports are static — they must be at the top level, not inside conditionals. This enables static analysis: bundlers (Webpack, Vite) can determine the dependency graph at build time and perform tree-shaking (removing unused exports). Exports are live bindings to the original variable (not copies), and they are read-only from the consumer side.
  • The distinction matters for three reasons: (1) Tree-shaking: only ES modules support it because imports are statically analyzable. If you import { debounce } from 'lodash-es', the bundler can drop the 90% of lodash you did not use. const _ = require('lodash') pulls in the entire library. (2) Top-level await: only available in ES modules. (3) Dual-package hazard: a Node.js package can be loaded both as ESM and CJS, creating two separate instances of the module. If module A imports the ESM version and module B requires the CJS version, they get different singleton instances, breaking shared state.
  • Mixing problems: require() cannot load an ES module directly (it is async). import can load CJS modules (Node.js wraps them). But the semantics differ: CJS has module.exports as a single value, while ESM has named exports. When you import a CJS module, the entire module.exports becomes the default export, and named imports may not work as expected. This causes “module has no named export” errors that confuse teams.
  • In Node.js, the module type is determined by: (1) file extension (.mjs = ESM, .cjs = CJS), or (2) "type": "module" in package.json (makes .js files ESM by default).
Follow-up: What is tree-shaking, and why does it only work with ES modules?Tree-shaking is a dead-code elimination technique where the bundler removes exported code that no consumer imports. It works with ES modules because import { x } from 'module' is a static declaration — the bundler can determine at build time that only x is used, and safely remove all other exports from the bundle. CommonJS require is dynamic: require(condition ? 'a' : 'b') or const lib = require('lib'); lib[dynamicKey]() — the bundler cannot know at build time which parts of the module are used, so it must include everything. The practical impact is significant: a React app importing { useState, useEffect } from React only includes those hooks (plus their dependencies) with tree-shaking. Without it, the entire React library is bundled. For large dependency trees (like date-fns, Material UI, lodash), tree-shaking can reduce bundle size by 60-80%.
Strong Answer:
  • The core problem: memory leaks from strong references. If you store an object as a key in a Map or in a closure, that reference prevents the garbage collector from reclaiming the object’s memory, even if nothing else in the application needs it. This is fine for data you intend to keep, but it creates leaks when you are associating metadata with objects that have their own lifecycle (DOM nodes, class instances, cache entries).
  • WeakMap: keys must be objects (not strings or numbers). The key reference is “weak” — it does not prevent garbage collection. If the key object has no other references, it is garbage collected, and the WeakMap entry is automatically removed. You cannot iterate a WeakMap (no .forEach, no .keys(), no .size) because entries can disappear at any time.
  • Use cases: (1) Private data for class instances: const privates = new WeakMap(); class Foo { constructor() { privates.set(this, { secret: 42 }); } getSecret() { return privates.get(this).secret; } }. When a Foo instance is garbage collected, its private data is automatically cleaned up. (2) DOM metadata: associating computed data with DOM nodes without preventing them from being GC’d when removed from the document. (3) Memoization caches where the cache key is an object: const cache = new WeakMap(); function expensiveCompute(obj) { if (cache.has(obj)) return cache.get(obj); const result = /* heavy work */; cache.set(obj, result); return result; }. The cache does not prevent obj from being collected.
  • WeakRef (ES2021): provides a weak reference to an object that you can dereference with .deref(). Returns undefined if the object has been garbage collected. Used in conjunction with FinalizationRegistry (which lets you run cleanup code when an object is GC’d). Use case: caches where you want to keep a reference as long as the object exists but do not want to prevent its collection.
  • Both are advanced features. The typical application developer rarely needs them directly — they are mostly used by library authors, framework internals, and performance-critical systems.
Follow-up: What is FinalizationRegistry and when would you pair it with WeakRef?FinalizationRegistry lets you register a callback that fires when a tracked object is garbage collected. const registry = new FinalizationRegistry((heldValue) => { console.log(heldValue + " was collected"); }); registry.register(myObject, "myObject");. The callback receives the “held value” (a plain value, not the object itself — since the object is already gone). You pair it with WeakRef when building a cache: store WeakRef wrappers in a Map, and use a FinalizationRegistry to clean up the map entry when the referenced object is collected. Without the registry, stale WeakRef entries (whose .deref() returns undefined) would accumulate in the map forever. Important caveats: GC timing is non-deterministic, so the callback may fire immediately or much later. You should never rely on FinalizationRegistry for correctness — only for resource cleanup optimization. The spec explicitly warns against using it for anything essential.
Strong Answer:
  • A tagged template literal is a function call where the function receives the template literal’s parts as arguments. The syntax is tagFunction\Hello name,age{name}, age `. The function receives: (1) an array of string segments (the static parts between interpolations): [‘Hello ’, ’, age ’, ”], and (2) the interpolated values as additional arguments: name, age`.
  • At a mechanical level: the engine splits the template at each ${} boundary. The string segments array always has one more element than the values array (there is always a string before the first interpolation and after the last). The tag function can process, transform, escape, or completely ignore any of these parts.
  • Real-world usage: (1) styled-components (CSS-in-JS): styled.div\color: {props => props.color}; padding: 20px;\`` -- the tag function extracts CSS rules and dynamic values, generates unique class names, and injects CSS into the document head. (2) **GraphQL gql tag**: `gql\`query { user(id: ) }`-- parses the GraphQL string into an AST at build time. (3) **html/sql template tags**: libraries likelit-htmluse tagged templates for efficient DOM rendering. SQL libraries use them for safe parameterized queries:sql`SELECT * FROM users WHERE id = $`-- the tag function ensuresuserId` is properly escaped, preventing SQL injection.
  • The security angle is key: tagged templates naturally separate trusted static strings from untrusted dynamic values. The tag function knows exactly which parts are developer-authored strings and which are runtime values, making it trivial to escape or sanitize only the dynamic parts. This is fundamentally safer than string concatenation.
  • Performance detail: the string segments array is frozen and cached by the engine. If the same tagged template is called multiple times (like in a render loop), the static parts array is the same object every time (referential equality). Tag functions can use this for caching: “If I have seen these exact static parts before, I can skip parsing and reuse the cached result.”
Follow-up: Can you write a simple ‘safe HTML’ tagged template that prevents XSS?
Usage: element.innerHTML = safeHTML\
$
`. The static HTML structure (
,
) passes through untouched, but userInputis HTML-escaped. If the user entered`, it renders as visible text, not executable code. This is exactly the principle that lit-html and other template-based rendering libraries use for XSS protection.