Introduction

ES6 (ECMAScript 2015) is the biggest update JavaScript has ever received. It arrived in June 2015, after six years of work, and it was the first revision of the language since ES5 landed in 2009. Today every modern browser and every current version of Node.js runs ES6 natively, so you do not need Babel or any other transpiler to use what we are about to see.

This post is a tour of 44 ES6 features, organized in 13 groups. Each feature has a short explanation and a code snippet you can paste into a file and run. There is no official list of “44 ES6 features” in the specification (the standard does not number its changes), so this list is a curated selection of what you actually use in real projects, plus a few interesting corners of the spec.

Group Features
Variables and scope 1 to 3
Functions 4 to 8
Template literals and strings 9 to 12
Destructuring 13 to 16
Enhanced object literals 17 to 20
Classes 21 to 23
Iterators and generators 24 to 27
Promises 28
Modules 29 to 31
Map, Set, WeakMap and WeakSet 32 to 35
Metaprogramming 36 to 38
New built-in APIs 39 to 43
The edge of the spec 44

Variables and Scope

1. let

let declares a variable that is scoped to the block where it is declared. Unlike var, it does not leak out of if, for or any {} block, and it cannot be declared twice in the same scope.

let count = 0
count = count + 1 // fine, let allows rebinding

count = 10 // still fine

Declaring let count a second time in the same scope throws a SyntaxError (count has already been declared), while var would have silently allowed it.

The practical rule: use const by default and switch to let only when you need to reassign.

2. const

const declares a binding that cannot be reassigned. Important detail: the binding is immutable, not necessarily the value. For objects and arrays you can still modify their contents.

const user = { name: "Ada" }

user.name = "Grace" // fine, the object itself was not replaced
user = { name: "Lin" } // TypeError: assignment to constant variable

const also prevents accidental rebinding in loops and callbacks, which removes a whole category of bugs.

3. Block Scoping and the Temporal Dead Zone

A block is anything between {}. Variables declared with let or const exist only inside it, and before their declaration they live in the temporal dead zone (TDZ): accessing them throws a ReferenceError instead of returning undefined.

{
  const secret = 42
}

console.log(secret) // ReferenceError: secret is not defined

if (true) {
  console.log(value) // ReferenceError: cannot access before initialization
  let value = "ready"
}

Block scoping also fixes the classic loop closure problem. With var, all callbacks share one variable; with let, each iteration gets its own binding:

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log("var:", i)) // 3, 3, 3
}

for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log("let:", i)) // 0, 1, 2
}

Functions

4. Arrow Functions

Arrow functions give you a shorter syntax for function expressions and let you omit function and, in many cases, return and the parameter parentheses.

const square = (n) => n * n
const greet = (name) => `Hello, ${name}!`
const double = (n) => {
  return n * 2
}

console.log(square(5)) // 25

With a single parameter you can skip the parentheses (n => n * n). With an expression body, return is implicit.

5. Lexical this in Arrow Functions

Arrows do not have their own this: they capture it from the surrounding function at the moment they are created. That kills the old pattern of copying self or that to keep a reference.

const timer = {
  seconds: 0,
  start() {
    setInterval(() => {
      this.seconds++ // `this` is still the timer object
    }, 1000)
  },
}

Two extra rules worth remembering: arrows have no arguments object and they cannot be used as constructors with new.

6. Default Parameter Values

Parameters can have defaults. The default is used only when the argument is undefined (passing null does not trigger it) and it can reference parameters declared before it.

function createUser(name, role = "viewer", active = true) {
  return { name, role, active }
}

console.log(createUser("Ada"))
// { name: "Ada", role: "viewer", active: true }

const shout = (message = "Hello", target = "world") =>
  `${message}, ${target}!`

7. Rest Parameters

Rest syntax collects all remaining arguments into a real array, replacing the clunky arguments object.

function sum(first, ...numbers) {
  return numbers.reduce((total, n) => total + n, first)
}

console.log(sum(1, 2, 3, 4)) // 10

const [head, ...tail] = [1, 2, 3]
console.log(head, tail) // 1 [2, 3]

The rest parameter must be the last one in the list, and there can be only one.

8. Spread Operator

The same ... syntax, used in calls, arrays and objects, expands an iterable or copies own enumerable properties.

const original = [1, 2, 3]
const copy = [...original, 4, 5] // [1, 2, 3, 4, 5]

console.log(Math.max(...original)) // 3

const defaults = { theme: "dark", lang: "en" }
const overrides = { lang: "es" }
const config = { ...defaults, ...overrides }
// { theme: "dark", lang: "es" }

launch(...args) // instead of launch.apply(null, args)

The copies are shallow: nested objects are still shared by reference.

Template Literals and Strings

9. Template Literals

Backticks create strings with interpolation and real line breaks, so concatenation with + and \n gymnastics are no longer necessary.

const name = "Ada"
console.log(`Hello, ${name}!`) // Hello, Ada!

const html = `
  <ul>
    <li>${name}</li>
  </ul>
`

Inside ${} you can run any expression: user.name.toUpperCase(), items.length, function calls, or even another template literal.

10. Tagged Template Literals

A function can “tag” a template literal. It receives the static string parts as an array and the interpolated values as separate arguments, which opens the door to escaping, highlighting, i18n or building SQL safely.

function highlight(strings, ...values) {
  return strings.reduce((result, str, i) => {
    const value = i < values.length ? `<mark>${values[i]}</mark>` : ""
    return result + str + value
  }, "")
}

const name = "Ada"
console.log(highlight`Hello ${name}!`)
// Hello <mark>Ada</mark>!

The tag also has access to strings.raw, and String.raw comes as a built-in tag: String.raw`C:\new\temp` returns the raw text without escape processing.

11. String Methods

ES6 added four methods you will use constantly: includes, startsWith, endsWith and repeat.

const email = "ada@example.com"

console.log(email.includes("@")) // true
console.log(email.startsWith("ada")) // true
console.log(email.endsWith(".com")) // true
console.log("ha".repeat(3)) // "hahaha"

Before ES6, checking a substring meant indexOf(...) !== -1 or a regular expression, and repeating a string meant a loop.

12. Unicode Strings and Regular Expression Flags

Strings are now easier to handle at the code point level. You can escape full Unicode characters with braces and iterate them correctly.

console.log("\u{1F600}") // an emoji in a string literal
console.log(String.fromCodePoint(0x1F600)) // same emoji

const smiley = "\u{1F600}"
console.log(smiley.codePointAt(0)) // 128512 (the full code point)
console.log([...smiley].length) // 1, not 2 surrogate halves

Regular expressions gained two flags: u for full Unicode support and y for sticky matching from lastIndex.

console.log(/\u{1F600}/u.test(smiley)) // true

const sticky = /test/y
sticky.lastIndex = 5
console.log("test test".match(sticky)) // matches only at position 5

Destructuring

13. Array Destructuring

Destructuring unpacks values by position, so assignments that used to span several lines become a single declaration.

const rgb = [255, 128, 0]

const [red, green, blue] = rgb
console.log(red, green, blue) // 255 128 0

const [, second] = rgb // skip the first position
console.log(second) // 128

const [first, ...rest] = rgb
console.log(first, rest) // 255 [128, 0]

14. Object Destructuring

Object destructuring unpacks by key name, and you can rename the target variable or reach nested properties in the same pattern.

const user = {
  id: 1,
  name: "Ada",
  address: { city: "London" },
}

const { name } = user // name = "Ada"
const { name: fullName } = user // rename on the fly: fullName = "Ada"
const { address: { city } } = user // nested: city = "London"

15. Defaults and Swapping

Destructuring accepts default values for missing entries, and it makes swapping two variables a one-liner.

const [a = 10, b = 20] = [5]
console.log(a, b) // 5 20 (b fell back to its default)

const { role = "admin" } = {}
console.log(role) // "admin"

let x = 1
let y = 2
;[x, y] = [y, x]
console.log(x, y) // 2 1

16. Destructuring in Parameters and Loops

Function parameters and loop heads accept destructuring patterns directly, which removes a lot of manual property access.

function draw({ x = 0, y = 0, color = "black" }) {
  console.log(x, y, color)
}

draw({ color: "red" }) // 0 0 red

const users = [
  { name: "Ada", age: 36 },
  { name: "Grace", age: 45 },
]

for (const { name, age } of users) {
  console.log(`${name} is ${age}`)
}

Enhanced Object Literals

17. Shorthand Property Names

When the variable name matches the property name, you can write it once.

const name = "Ada"
const age = 36

const user = { name, age }
// identical to { name: name, age: age }

18. Shorthand Method Names

Functions stored in objects can drop the function keyword.

const math = {
  add(a, b) {
    return a + b
  },
  sub(a, b) {
    return a - b
  },
}

console.log(math.add(2, 3)) // 5

19. Computed Property Names

Property names can be expressions inside [], which is very handy when building objects from dynamic data.

const field = "id"
const prefix = "user"

const record = {
  [field]: 123,
  [`${prefix}Name`]: "Ada",
}

console.log(record) // { id: 123, userName: "Ada" }

20. Getters and Setters

Object literals and classes can define get and set accessors, so validation or derived values can live behind a plain property.

const temperature = {
  celsius: 0,
  get fahrenheit() {
    return (this.celsius * 9) / 5 + 32
  },
  set fahrenheit(value) {
    this.celsius = ((value - 32) * 5) / 9
  },
}

console.log(temperature.fahrenheit) // 32
temperature.fahrenheit = 212
console.log(temperature.celsius) // 100

Classes

21. class Declarations and Methods

class is syntactic sugar over prototypes, but it makes the intent explicit: a constructor for initialization and regular methods shared by every instance.

class Animal {
  constructor(name) {
    this.name = name
  }

  speak() {
    return `${this.name} makes a sound`
  }
}

const pet = new Animal("Rex")
console.log(pet.speak()) // Rex makes a sound

Three details: classes are not hoisted (they live in a TDZ), they always run in strict mode, and their methods are non-enumerable, so they do not show up in for...in.

22. Inheritance with extends and super

extends sets up the prototype chain and super calls the parent implementation (it is mandatory in a child constructor before using this).

class Dog extends Animal {
  constructor(name) {
    super(name) // must run before this.*
  }

  speak() {
    return `${this.name} barks`
  }
}

const dog = new Dog("Rex")
console.log(dog.speak()) // Rex barks
console.log(dog instanceof Animal) // true

23. Static Members

The static keyword puts methods on the class itself instead of on its instances, which is the classic spot for factories and helpers.

class User {
  constructor(name) {
    this.name = name
  }

  static create(name) {
    return new User(name)
  }
}

const ada = User.create("Ada")
console.log(ada instanceof User) // true
console.log(ada.create) // undefined, create lives on User

Static methods are inherited too: a subclass can call or override the parent’s statics.

Iterators and Generators

24. The for…of Loop

for...of iterates over the values of anything iterable: arrays, strings, Map, Set or your own objects.

for (const value of [10, 20, 30]) {
  console.log(value) // 10, 20, 30
}

for (const char of "abc") {
  console.log(char) // "a", "b", "c"
}

for (const [key, value] of new Map([["a", 1]])) {
  console.log(key, value) // "a" 1
}

Use for...in for object keys and for...of for values; mixing them up is a common source of bugs.

25. The Iterator Protocol

Any object that implements [Symbol.iterator]() and returns an object with a next() method becomes iterable. next() must return { value, done }.

const range = {
  from: 1,
  to: 5,
  [Symbol.iterator]() {
    let current = this.from
    const last = this.to
    return {
      next() {
        return current <= last
          ? { value: current++, done: false }
          : { value: undefined, done: true }
      },
    }
  },
}

for (const n of range) {
  console.log(n) // 1, 2, 3, 4, 5
}

This one protocol is what makes for...of, the spread operator and destructuring work on arrays, strings and collections alike.

26. Generators with function*

A generator is a function that can pause and resume, producing values on demand with yield. Calling it returns an iterator.

function* idGenerator() {
  let id = 1
  while (true) {
    yield id++
  }
}

const ids = idGenerator()
console.log(ids.next()) // { value: 1, done: false }
console.log(ids.next()) // { value: 2, done: false }

Because values are produced lazily, generators are perfect for infinite sequences, paginated APIs or walking large structures without loading everything at once.

27. Generator Methods

The * marker also works in object literals and classes, so an object can expose its own iterator without defining a separate function.

const collection = {
  items: [1, 2, 3],
  *values() {
    for (const item of this.items) {
      yield item
    }
  },
}

console.log([...collection.values()]) // [1, 2, 3]

class Range {
  constructor(from, to) {
    this.from = from
    this.to = to
  }

  *[Symbol.iterator]() {
    for (let i = this.from; i <= this.to; i++) {
      yield i
    }
  }
}

Promises

28. Promises

A Promise represents a value that will be available now, later or never. It settles exactly once: it resolves or rejects, and every consumer attached afterwards sees the same result.

function getUser(id) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      if (id > 0) resolve({ id, name: "Ada" })
      else reject(new Error("invalid id"))
    }, 500)
  })
}

getUser(1)
  .then((user) => console.log(user.name)) // Ada
  .catch((error) => console.error(error.message)) // runs only on rejection
  .finally(() => console.log("done")) // always runs

Combinators handle groups of tasks:

// inside an async function
const [users, posts] = await Promise.all([fetchUsers(), fetchPosts()])
const fastest = await Promise.race([slowMirror, fastMirror])

Promise.all rejects as soon as one member rejects, while Promise.race settles with the first member that settles. Every later async feature (including async/await, which came in ES2017) is built on top of this primitive.

Modules

29. Named Imports and Exports

Modules replace the mix of scripts, bundler formats and global variables. Each file is a module, and you export exactly what others may use.

// utils.js
export const PI = 3.14

export function circleArea(radius) {
  return PI * radius * radius
}

// main.js
import { PI, circleArea } from "./utils.js"

console.log(circleArea(2)) // 12.56

In the browser you load the entry with <script type="module" src="main.js"></script>; paths inside import must include the file extension.

30. Default Exports and Namespace Imports

A module can have one default export (commonly a class or the main function) plus any number of named ones.

// renderer.js
export default class Renderer {
  render(node) {
    console.log("rendering", node)
  }
}

// main.js
import Renderer from "./renderer.js"
import * as utils from "./utils.js" // namespace object

console.log(utils.PI)
console.log(new Renderer())

31. Re-exports

export ... from forwards names coming from another module, which is how barrel files (index.js) aggregate an API in one place.

// geometry.js
export { circleArea, circleArea as area } from "./shapes.js"
export * from "./shapes.js"
export { default as Shape } from "./shape.js"

Imports are live bindings: if the exporting module changes a value, importers see the new value, but they cannot reassign it.

Map, Set, WeakMap and WeakSet

32. Map

Map is a collection of key/value pairs where keys can be any type, it keeps insertion order and it has a size property.

const scores = new Map()

scores.set("ada", 10)
scores.set(42, "answer")
scores.set(true, "yes")

console.log(scores.get("ada")) // 10
console.log(scores.size) // 3

for (const [key, value] of scores) {
  console.log(key, value)
}

Use a Map instead of a plain object when keys are dynamic, when you need guaranteed insertion order or when you want to avoid collisions with prototype properties such as "constructor".

33. Set

Set stores unique values and implements add, has, delete and size.

const unique = new Set([1, 1, 2, 3])
console.log(unique.size) // 3

unique.add(4)
unique.delete(1)

console.log([...unique]) // [2, 3, 4]

const tags = new Set(article.tags.split(","))

Removing duplicates from an array is now a one-liner: [...new Set(array)].

34. WeakMap

A WeakMap only accepts objects as keys, and entries disappear automatically once the key is garbage collected. It cannot be iterated, and that is exactly the point: it holds private data without leaking memory.

const privateData = new WeakMap()

class Person {
  constructor(name) {
    privateData.set(this, { name })
  }

  getName() {
    return privateData.get(this).name
  }
}

const ada = new Person("Ada")
console.log(ada.getName()) // Ada

Nobody outside the class can reach privateData for a given instance, and when the instance dies, its entry goes with it.

35. WeakSet

WeakSet is the same idea for groups of objects: only objects can be added, there is no iteration, and members are collected with their references.

const visited = new WeakSet()

function walk(node) {
  if (visited.has(node)) return
  visited.add(node)
  // visit node.children...
}

It works as a memory-safe marker set: tracking visited nodes or DOM elements without preventing them from being collected.

Metaprogramming

36. Symbol

A Symbol is a unique and immutable identifier, useful as a property key that will never collide with another key.

const id = Symbol("id")

const user = {
  name: "Ada",
  [id]: 123,
}

console.log(user[id]) // 123
console.log(Object.keys(user)) // ["name"], symbols are hidden here
console.log(JSON.stringify(user)) // {"name":"Ada"}, symbols are skipped

Symbol.for("key") reuses a symbol from a global registry, and the language ships well-known symbols such as Symbol.iterator, Symbol.toPrimitive and Symbol.toStringTag to hook into built-in behavior.

37. Proxy

A Proxy wraps an object and intercepts its operations through traps like get, set, has or deleteProperty.

const target = { name: "Ada", age: 36 }

const logged = new Proxy(target, {
  get(obj, prop) {
    console.log(`reading ${prop}`)
    return obj[prop]
  },
  set(obj, prop, value) {
    console.log(`writing ${prop} = ${value}`)
    obj[prop] = value
    return true
  },
})

logged.name // logs "reading name"
logged.age = 37 // logs "writing age = 37"

Proxies power validation, logging, access control and the reactivity systems of modern frameworks (Vue 3 builds its reactivity on Proxy).

38. Reflect

Reflect exposes the same operations as the proxy traps, but as plain functions that return a boolean or throw instead of relying on quirky operator behavior.

const user = { name: "Ada" }

console.log(Reflect.get(user, "name")) // "Ada"
console.log(Reflect.set(user, "name", "Grace")) // true
console.log(Reflect.has(user, "name")) // same as "name" in user
console.log(Reflect.deleteProperty(user, "name")) // true

const instance = Reflect.construct(User, ["Ada"]) // same as new User("Ada")

Whenever you used Object.defineProperty or obj[key] = value for dynamic property work, Reflect gives you a cleaner and more predictable API.

New Built-in APIs

39. Object Statics: assign, is and Friends

Object.assign merges objects, Object.is compares values with better semantics and Object.getOwnPropertySymbols reveals symbol keys.

const merged = Object.assign({}, { a: 1 }, { b: 2 })
// { a: 1, b: 2 }

console.log(Object.is(NaN, NaN)) // true, unlike ===
console.log(Object.is(0, -0)) // false

const symbols = Object.getOwnPropertySymbols({ [Symbol("x")]: 1 })
console.log(symbols.length) // 1

Object.assign copies enumerable own properties by value, so the result is always a shallow copy.

40. Array Creation and Mutation Helpers

Array.from converts anything array-like or iterable into a real array, Array.of builds arrays from individual values, and fill/copyWithin edit in place.

console.log(Array.from("abc")) // ["a", "b", "c"]
console.log(Array.from({ length: 3 }, (_, i) => i + 1)) // [1, 2, 3]
console.log(Array.of(1, 2, 3)) // [1, 2, 3]

const letters = ["a", "b", "c"]
letters.fill("z", 1) // ["a", "z", "z"]
letters.copyWithin(0, 1) // ["z", "z", "z"]

Array.from also fixes the classic problem of converting a NodeList returned by document.querySelectorAll into an array.

41. Array Search and Iteration Helpers

Two methods cover most search needs, and the iterator methods expose keys, values and pairs.

const users = [
  { id: 1, name: "Ada" },
  { id: 2, name: "Grace" },
]

console.log(users.find((u) => u.id === 2)) // { id: 2, name: "Grace" }
console.log(users.findIndex((u) => u.id === 2)) // 1

console.log([...users.keys()]) // [0, 1]
console.log([...users.values()][0]) // { id: 1, name: "Ada" }
console.log([...users.entries()][1]) // [1, { id: 2, name: "Grace" }]

find returns the first matching value (or undefined), findIndex returns its position, and both replace the filter-then-access-0 pattern.

42. Number APIs and Numeric Literals

Number gained precise checks and safety constants, and the language added binary and octal literals.

console.log(Number.isInteger(42)) // true
console.log(Number.isInteger(4.2)) // false
console.log(Number.isNaN("hello")) // false, the global isNaN would say true

console.log(Number.MAX_SAFE_INTEGER) // 9007199254740991
console.log(Number.MIN_SAFE_INTEGER) // -9007199254740991
console.log(Number.EPSILON) // 2.220446049250313e-16

console.log(0b1010) // 10 in binary
console.log(0o17) // 15 in octal

Number.parseInt and Number.parseFloat behave like the globals but without the implicit coercion quirks, which makes parsing explicit.

43. Math APIs

Math received a batch of functions that used to require hand-written implementations.

console.log(Math.trunc(4.9)) // 4, unlike Math.floor for negatives
console.log(Math.sign(-4.2)) // -1
console.log(Math.cbrt(27)) // 3

console.log(Math.log2(8)) // 3
console.log(Math.log10(1000)) // 3
console.log(Math.hypot(3, 4)) // 5
console.log(Math.imul(0xffffffff, 5)) // correct 32-bit multiplication
console.log(Math.clz32(8)) // 28 leading zeros

The Edge of the Spec

44. Proper Tail Calls

ES6 requires engines to run tail calls in constant memory: when the last action of a function is a call to another function, the engine can reuse the current stack frame instead of growing the stack.

function factorial(n, acc = 1) {
  if (n === 1) return acc
  return factorial(n - 1, n * acc) // tail position
}

console.log(factorial(10000)) // no stack overflow in a conforming engine

Honest caveat: this is the least implemented item on the list. Only JavaScriptCore (Safari) optimized tail calls, and the rest of the industry declined the feature because it makes stack traces harder to read. It is still part of the specification, so it is worth knowing what “tail call optimized” means when you read it somewhere.

Two features that almost made the list: new.target, which tells a constructor whether it was called with new, and subclassable built-ins, which let you write class MyList extends Array {}.

Cheat Sheet

The full list in one place:

# Feature What it gives you
1 let Block-scoped variable you can reassign
2 const Binding you cannot reassign
3 Block scoping and TDZ No leaks out of {}, access before declaration throws
4 Arrow functions Concise function syntax
5 Lexical this Arrows capture this from the enclosing scope
6 Default parameters Fallback values when an argument is undefined
7 Rest parameters Remaining arguments as a real array
8 Spread operator Expand iterables and copy properties
9 Template literals Interpolation and multiline strings with backticks
10 Tagged templates Functions that process raw string parts and values
11 String methods includes, startsWith, endsWith, repeat
12 Unicode and flags \u{}, codePointAt, regex u and y
13 Array destructuring Unpack values by position
14 Object destructuring Unpack values by key, including nested
15 Defaults and swapping Default values and one-line swaps
16 Destructuring in params Patterns in parameters and for heads
17 Shorthand properties { name } instead of { name: name }
18 Shorthand methods add() {} instead of add: function () {}
19 Computed property names Dynamic keys with [expr]
20 Getters and setters Accessors behind plain properties
21 Classes class with constructor and shared methods
22 extends and super Prototype-chain inheritance
23 Static members Methods that live on the class
24 for...of Loop over values of any iterable
25 Iterator protocol [Symbol.iterator] + next() makes anything iterable
26 Generators function* with yield for lazy values
27 Generator methods *method() in objects and classes
28 Promises Values available now, later or never, with then/catch
29 Named imports/exports Per-file API surface instead of globals
30 Default and namespace One default export, import * as
31 Re-exports Barrel files with export ... from
32 Map Any-type keys, insertion order, size
33 Set Unique values with add/has/delete
34 WeakMap Private data keyed by objects, garbage collected
35 WeakSet Memory-safe object markers
36 Symbol Unique property keys, including well-known symbols
37 Proxy Interception of any object operation
38 Reflect Object operations as plain functions
39 Object.assign/is Shallow merges and better equality
40 Array.from/fill Build and mutate arrays fluently
41 find/findIndex Search without filtering first
42 Number APIs isInteger, safe bounds, 0b and 0o literals
43 Math APIs trunc, sign, hypot, imul and friends
44 Proper tail calls Constant-memory recursion in strict mode

Best Practices

  1. Default to const, use let only when you must reassign, never use var: block scoping removes entire classes of bugs
  2. Prefer destructuring but keep it readable: deep patterns with renames and defaults are great, a pattern nested five levels deep is not
  3. Use arrow functions for callbacks, regular functions for methods: arrows inherit this, so object methods and constructors usually want the classic form
  4. Pick Map and Set when keys are dynamic or you need uniqueness: plain objects are still fine for fixed, known structures
  5. Treat Promises as the base layer: always attach catch, never leave a floating promise, and reach for Promise.all when tasks are independent
  6. Design small modules with explicit exports: a clear export list is documentation that the bundler cannot drift from
  7. Reach for Symbol when you need metadata that must not collide (debug ids, internal markers) and for the well-known symbols when you want to hook into iteration or conversion
  8. Use Proxy for cross-cutting concerns (logging, validation, observability) instead of wrapping every object by hand

Conclusion

These 44 features are the foundation of the JavaScript you write today. Block scoping and const changed how we declare state, arrow functions and destructuring changed how we read code, classes and modules changed how we structure projects, and Promises plus iterators changed how we handle asynchrony and collections.

Notice how the features connect: the iterator protocol (25) makes for...of (24), spread (8) and destructuring (13) work; Promises (28) later enabled async/await; Symbol (36) is what makes generators (26) and custom iteration possible at all. ES6 is not a list of isolated tricks, it is a coherent design that everything after it (ES2016 to today) keeps building on.

If anything was not clear, leave it in the comments.

TypeScript Guide EP15: Mapped Types and neverNestJS Guide EP11: Modules - Imports, Exports and @Global