From the archive: this article was published in June 2016 and is kept for reference. Tools, versions and figures may have changed since.
On this page 13
ECMAScript 2015, usually called ES6, was the largest single update JavaScript has had. It added block scoping, classes, modules, new collections and a dozen syntax shortcuts. Since then the language has shipped a smaller release every year (ES2016, ES2017 and so on), and every current browser and Node.js release supports ES2015 in full.
This guide covers the ES2015 features that show up in almost every codebase, plus a few later additions that are now just as common. Each section shows the older pattern only where it helps explain why the new one exists.
let and const
var is function-scoped and hoisted, which makes it easy to reuse a variable by accident. let and const are scoped to the nearest block ({ ... }).
for (var i = 0; i < 3; i++) {
setTimeout(() => console.log('var', i)) // var 3, three times
}
for (let j = 0; j < 3; j++) {
setTimeout(() => console.log('let', j)) // let 0, let 1, let 2
}
Each loop iteration gets its own j, so the callbacks see the value from their own iteration.
const stops you from reassigning the binding, not from changing the value it points to:
const settings = { theme: 'dark' }
settings.theme = 'light' // allowed: the object is mutable
settings = {} // TypeError: Assignment to constant variable
Reading a let or const variable before its declaration throws a ReferenceError instead of returning undefined. The stretch of code before the declaration is called the temporal dead zone.
A reasonable default: use const everywhere, switch to let when you need to reassign, and leave var for code you have not migrated yet.
Arrow functions
Arrow functions are shorter, and they do not create their own this. They take this from the surrounding code, which removes the old const self = this workaround.
class Timer {
constructor() {
this.seconds = 0
}
start() {
setInterval(() => {
this.seconds += 1 // `this` is the Timer instance
}, 1000)
}
}
A single expression after the arrow is returned automatically. Wrap an object literal in parentheses so the braces are not read as a function body:
const toOption = user => ({ value: user.id, label: user.name })
Arrow functions also have no arguments object and cannot be called with new. Use a regular function or method when you need a dynamic this, for example as an object method that reads its own properties.
Classes
Classes are a cleaner syntax over JavaScript’s existing prototype model. They do not add a new object system, but they make constructors, inheritance and static members far easier to read.
class Shape {
constructor(name) {
this.name = name
}
describe() {
return `${this.name} with area ${this.area().toFixed(2)}`
}
static compare(a, b) {
return a.area() - b.area()
}
}
class Circle extends Shape {
constructor(radius) {
super('Circle') // must run before `this` is used
this.radius = radius
}
area() {
return Math.PI * this.radius ** 2
}
}
const shapes = [new Circle(2), new Circle(1)]
shapes.sort(Shape.compare)
console.log(shapes[0].describe()) // Circle with area 3.14
A few rules worth knowing:
- Class bodies always run in strict mode.
- A class must be called with
new; calling it as a function throws. - Class declarations are not usable before the line that declares them, unlike function declarations.
- In a subclass constructor, call
super()before touchingthis.
Later editions added public and private fields (#count), static blocks and private methods (ES2022). Private fields are enforced by the language, not by naming convention:
class Counter {
#count = 0
increment() {
return ++this.#count
}
}
Default, rest and spread
Default parameters replace the x = x || fallback pattern, which broke for falsy values such as 0 or ''.
function paginate(items, page = 1, size = 20) {
const start = (page - 1) * size
return items.slice(start, start + size)
}
Defaults apply only when the argument is undefined. Passing null gives you null. Defaults are evaluated on every call, and a later default can use an earlier parameter:
function createTag(name, slug = name.toLowerCase().replace(/\s+/g, '-')) {
return { name, slug }
}
A rest parameter collects the remaining arguments into a real array, unlike the old arguments object:
function logEvent(type, ...details) {
console.log(`[${type}]`, details.join(' | '))
}
logEvent('deploy', 'api', 'v2.4.1', 'success')
Spread does the reverse: it expands an array (or any iterable) into individual values. The same ... works for copying and merging arrays, and since ES2018 for objects too:
const scores = [72, 91, 64]
Math.max(...scores) // 91
const base = { retries: 3, timeout: 5000 }
const config = { ...base, timeout: 10000 } // later keys win
Object spread makes a shallow copy. Nested objects are still shared, so use structuredClone() when you need a deep copy.
Destructuring
Destructuring pulls values out of arrays and objects into variables, with optional renaming and defaults.
const response = { status: 200, data: { items: [], total: 0 } }
const {
status,
data: { items, total: count = 0 }
} = response
const [first, , third = 'none'] = ['a', 'b']
// first = 'a', third = 'none'
It works in function parameters too, which is how most React components and options objects are written:
function connect({ host = 'localhost', port = 5432, ssl = false } = {}) {
return `${ssl ? 'postgres+ssl' : 'postgres'}://${host}:${port}`
}
connect({ port: 6543 })
The = {} at the end lets the function be called with no argument at all. Swapping two variables is also a one-liner: [a, b] = [b, a].
Template literals
Backtick strings support embedded expressions and line breaks:
const order = { id: 'A-1042', items: 3 }
const message = `Order ${order.id} shipped with ${order.items} items.
Track it from your account page.`
A tag function placed before the template receives the string parts and the values separately. Libraries use this for escaping SQL or HTML and for styled components:
function upper(strings, ...values) {
return strings.reduce(
(out, part, i) => out + part + (values[i]?.toUpperCase() ?? ''),
''
)
}
upper`status: ${'ok'}` // 'status: OK'
Shorter object literals
Three small additions remove a lot of repetition:
const name = 'billing'
const field = 'region'
const service = {
name, // shorthand for name: name
[field]: 'eu-west', // computed key
start() {
// method shorthand
return `${this.name} started`
}
}
Modules
ES modules give JavaScript a built-in way to split code across files. Imports and exports are static, so tools can see exactly what each file uses and remove unused code (tree shaking).
// math.js
export const round = (value, places = 2) => Number(value.toFixed(places))
export default function sum(...values) {
return values.reduce((a, b) => a + b, 0)
}
// app.js
import sum, { round } from './math.js'
console.log(round(sum(0.1, 0.2))) // 0.3
For code that should load only when needed, import() returns a promise for the module.
In browsers, load module files with <script type="module">. In Node.js, use the .mjs extension or set "type": "module" in package.json. Many Node.js projects still use CommonJS (require), and recent Node.js releases can require() most ES modules, which has made mixing the two less painful.
Map, Set, WeakMap and WeakSet
Before ES2015, plain objects did double duty as dictionaries. Map is the purpose-built version: keys can be any value (including objects), insertion order is kept, and size is a property.
const visits = new Map()
visits.set('/pricing', 12).set('/docs', 40)
visits.get('/docs') // 40
visits.has('/blog') // false
for (const [path, count] of visits) {
console.log(path, count)
}
Set stores unique values. Its most common use is removing duplicates:
const tags = ['react', 'node', 'react', 'css']
const unique = [...new Set(tags)] // ['react', 'node', 'css']
WeakMap and WeakSet hold their keys weakly: if nothing else references a key object, it can be garbage collected along with its entry. That makes them a good fit for attaching data to objects you do not own, such as DOM nodes, without causing memory leaks. The trade-off is that you cannot iterate over them or read their size.
Iterators, for…of and generators
Arrays, strings, Maps, Sets and many DOM collections are iterable. for...of loops over the values of any iterable, which is what you usually want, while for...in loops over property names.
A generator function (function*) produces an iterator. Each yield hands out one value and pauses until the next one is requested:
function* idGenerator(prefix) {
let n = 1
while (true) {
yield `${prefix}-${n++}`
}
}
const ids = idGenerator('INV')
ids.next().value // 'INV-1'
ids.next().value // 'INV-2'
Because values are produced on demand, a generator can describe an endless sequence. Application code rarely needs to write generators directly, but they power libraries such as redux-saga, and the same idea is behind async iteration (for await...of) over streams.
Promises
ES2015 also made Promise part of the language, which turned callback-heavy async code into chainable values. ES2017 then added async/await on top of promises. Both are covered in detail in callbacks, promises and async/await in JavaScript.
Later additions you will see everywhere
A few features from later editions are now as common as anything in ES2015:
// Optional chaining (ES2020): stop at null or undefined instead of throwing
const zip = user?.address?.zipcode
// Nullish coalescing (ES2020): fall back only on null or undefined, not on 0 or ''
const pageSize = settings.pageSize ?? 20
// Exponent operator (ES2016) and Array.prototype.includes (ES2016)
const area = side ** 2
const isAdmin = roles.includes('admin')
// Object.entries / Object.fromEntries (ES2017, ES2019)
const doubled = Object.fromEntries(
Object.entries(prices).map(([key, value]) => [key, value * 2])
)
The difference between ?? and || matters: count || 10 replaces a real 0 with 10, while count ?? 10 keeps it.
Do you still need a transpiler?
A transpiler rewrites source code into an equivalent form that a target environment can run. In 2016 that meant converting ES2015 to ES5 so it would work in older browsers. Today every supported browser and Node.js release runs ES2015 natively, so that original reason has mostly gone away.
You still run code through a transpiler when:
- You write TypeScript or JSX. Neither runs in a browser as written. The compiler strips types and turns JSX into function calls.
- You support old targets. Embedded webviews, smart TVs or long-lived enterprise browsers may lag behind. A
browserslistsetting tells your tools which syntax to lower. - You use syntax that has not shipped everywhere yet, such as decorators.
The common tools:
- Babel: the long-standing, plugin-based option. Use it when you need a specific plugin or fine-grained control.
- esbuild: a very fast bundler and transpiler written in Go.
- SWC: a fast Rust-based compiler. Next.js uses it in place of Babel by default.
- TypeScript (
tsc): type-checks and can also emit JavaScript for a chosen target.
In most projects you do not call these directly. Your framework or bundler picks one and configures it, and your job is to set the right target. Aim it at the environments you actually support: lowering modern syntax to ES5 produces larger, slower code, and modern browsers do not need it.
For a functional style built on these features, read functional programming in JavaScript. For applying them on the server, see Node.js best practices.
This article originally appeared on lightrains.com
Leave a comment
To make a comment, please send an e-mail using the button below. Your e-mail address won't be shared and will be deleted from our records after the comment is published. If you don't want your real name to be credited alongside your comment, please specify the name you would like to use. If you would like your name to link to a specific URL, please share that as well. Thank you.
Comment via email