ES2015 Is Official: The Parts Worth Using Every Day
JavaScript has a new standard. Last Wednesday, June 17, the Ecma General Assembly met in Montreux and approved ECMA-262 6th Edition, which carries the official title ECMAScript 2015 Language Specification. Most of us have been calling it ES6 for a long time, and you’ll keep hearing both names. The year in the new name matters, though, because TC39 has committed to putting out a new edition every year from here on instead of making us wait another six years.
The spec’s own introduction calls this the most extensive update to ECMAScript since the first edition in 1997, with generators, proxies, symbols, new collections and a lot more. This post skips most of that and sticks to the parts that change the JavaScript a web developer writes on an ordinary Tuesday: let and const, arrow functions, template literals, destructuring, default and rest parameters, classes, modules and Promises. After that comes the less exciting part, which is what you can actually run in a browser right now.
Every example below uses only syntax from the 6th Edition.
Block scope with let and const
var has always been scoped to the whole function, no matter where you write it. let and const are scoped to the nearest block, the pair of curly braces around them. The difference shows up most painfully in loops that create callbacks.
var viaVar = [];
for (var i = 0; i < 3; i++) {
viaVar.push(function () { return i; });
}
let viaLet = [];
for (let j = 0; j < 3; j++) {
viaLet.push(function () { return j; });
}
console.log(viaVar.map(function (f) { return f(); })); // [ 3, 3, 3 ]
console.log(viaLet.map(function (f) { return f(); })); // [ 0, 1, 2 ]
With var there’s one i for the whole function, so all three callbacks see its final value. A for loop with let gets a fresh copy of j on every pass, so each callback keeps the value from its own iteration. No more wrapping the body in an immediately invoked function just to capture a number.
There’s also a behavior change that catches people. A let or const variable exists from the start of its block, but touching it before the declaration line runs throws a ReferenceError. With var you’d quietly get undefined, so this is a bug you used to ship and now get told about.
const works like let except that it needs an initializer and the binding can’t be reassigned. Assigning to it throws a TypeError. It doesn’t freeze the value, though, so const config = { theme: 'dark' } still lets you change config.theme. My take is to reach for const by default, use let when you really do reassign, and let var fade out of new code.
Arrow functions and a this that stays put
Arrow functions are shorter, which is nice, but the real change is what they don’t have. The spec says an arrow function doesn’t create its own this, arguments, super or new.target. Those names resolve to whatever they mean in the surrounding code.
const cart = {
total: 0,
addAll(prices) {
prices.forEach(price => {
this.total += price;
});
return this.total;
}
};
const toItem = (name, qty) => ({ name, qty });
console.log(cart.addAll([5, 10, 15])); // 30
console.log(toItem('coffee', 2)); // { name: 'coffee', qty: 2 }
Inside addAll, the callback’s this is the same this as the method’s, which is cart. That’s the end of var self = this and .bind(this) for callbacks. Two gotchas are worth remembering. Don’t use an arrow for a method you call as object.method(), because it won’t receive that object as this. And if an arrow should return an object literal, wrap it in parentheses like toItem does, or the braces get read as a function body. Arrow functions also can’t be used with new.
Template literals
Backtick strings let you drop expressions in with ${} and span multiple lines without any concatenation.
const user = { name: 'Sam', items: 3 };
console.log(`${user.name} has ${user.items} items in the cart`);
The feature that deserves more attention is the tagged template. Put a function name in front of the backtick, and that function receives the literal string pieces and the interpolated values separately, so it can decide how to join them. That makes it a good place to escape user input.
function escapeHtml(strings, ...values) {
let result = strings[0];
values.forEach((value, i) => {
const safe = String(value)
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
result += safe + strings[i + 1];
});
return result;
}
const comment = '<img src=x onerror=alert(1)>';
console.log(escapeHtml`<p>${comment}</p>`);
// <p><img src=x onerror=alert(1)></p>
The security point cuts both ways. A plain template literal escapes nothing, so building HTML out of user input with backticks is exactly as dangerous as doing it with +. And this particular tag is only right for element text and quoted attribute values, not for things like URLs or inline scripts.
Destructuring
Destructuring pulls values out of objects and arrays using a pattern that looks like the literal you’d write to build them.
const response = { status: 200, body: { id: 7, tags: ['news', 'js'] } };
const { status, body: { id, tags: [firstTag] } } = response;
console.log(status, id, firstTag); // 200 7 news
function connect({ host = 'localhost', port = 80 } = {}) {
return `${host}:${port}`;
}
console.log(connect({ port: 8080 })); // localhost:8080
console.log(connect()); // localhost:80
The connect function shows the pattern I’d expect to see everywhere soon: an options object destructured right in the parameter list, with defaults for each property and an empty object as the default for the whole thing. Swapping two variables is now [a, b] = [b, a]. One thing to watch is that destructuring null or undefined throws a TypeError, which is why that = {} at the end of the parameter matters.
Default and rest parameters
Default parameters replace the old color = color || 'gray' line, with one important difference. A default only kicks in when the argument is undefined, so 0, '' and null all get through as they are. Defaults can also refer to earlier parameters.
function label(text, color = 'gray', ...classes) {
return `${text} [${color}] ${classes.join(' ')}`;
}
console.log(label('Live', undefined, 'bold', 'big')); // Live [gray] bold big
console.log(label('Live', null, 'bold')); // Live [null] bold
function range(start, end = start + 3) {
return [start, end];
}
console.log(range(5)); // [ 5, 8 ]
A rest parameter like ...classes collects the remaining arguments into a real array, so you get map and join without converting arguments first. The same three dots in a call spread an array back out, so Math.max(...scores) finally replaces Math.max.apply(null, scores).
Classes
Classes give JavaScript one standard way to write constructor functions and prototypes, instead of every library inventing its own.
class Shape {
constructor(name) {
this.name = name;
}
describe() {
return `${this.name} with area ${this.area()}`;
}
}
class Rect extends Shape {
constructor(width, height) {
super('rect');
this.width = width;
this.height = height;
}
area() {
return this.width * this.height;
}
}
console.log(new Rect(4, 5).describe()); // rect with area 20
console.log(typeof Shape); // function
Under the hood it’s still prototypes, which is why typeof Shape is 'function'. The class form does tighten a few things up. All the code in a class body is strict mode code. Calling a class without new throws a TypeError. In a subclass constructor, touching this before calling super() throws a ReferenceError. And class declarations aren’t hoisted like function declarations, so using a class above the line that defines it fails the same way an early let does.
Modules with import and export
Modules are the biggest structural change for front end code, because for the first time the language itself has a way to split code across files. Each file is its own scope, and you choose what leaves it.
// prices.js
export const TAX_RATE = 0.08;
export function withTax(amount) {
return amount * (1 + TAX_RATE);
}
export default function formatPrice(amount) {
return `$${amount.toFixed(2)}`;
}
// main.js
import formatPrice, { withTax } from './prices.js';
import * as prices from './prices.js';
console.log(formatPrice(withTax(10))); // $10.80
console.log(prices.TAX_RATE); // 0.08
Two details are different from the module patterns you might know from Node or AMD. Module code is always strict, with no 'use strict' needed. And an import isn’t a copy of the exported value. It’s a live, read only binding to the exporting module’s variable, so if that module exports let count and later changes it, importers see the new value, while an importer that tries to assign count = 5 gets a TypeError.
Promises
If you’ve used a promise library, the shape will look familiar, and now Promise is built into the language. A promise stands for a result that isn’t ready yet. then returns a new promise, which is what lets you chain steps, and a rejection skips ahead to the next catch.
function loadUser(id) {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (id > 0) {
resolve({ id, name: `user${id}` });
} else {
reject(new Error(`no user ${id}`));
}
}, 10);
});
}
loadUser(1)
.then(user => user.name.toUpperCase())
.then(name => console.log(name)) // USER1
.catch(err => console.log(err.message));
Promise.all([loadUser(2), loadUser(3)])
.then(users => console.log(users.map(u => u.id))); // [ 2, 3 ]
Promise.all waits for every promise and rejects as soon as one of them does, and Promise.race settles with whichever finishes first. Callbacks passed to then never run synchronously, even when the promise is already resolved, because the spec queues them as jobs. The part to be careful with is that the 2015 spec says nothing about reporting a rejection nobody handled, so a chain without a catch at the end can fail silently depending on where it runs.
What browsers can run right now
The standard is final, but implementations are in very different places.
On the Chrome side, the Chrome 41 beta announcement in January covered template literals and block scoped let, and the Chrome 42 beta post in March added classes, but only for JavaScript written in strict mode. Mozilla’s ES6 In Depth series has been noting Firefox’s status as it goes. Template strings have been in Firefox since version 34, and Mozilla wrote in mid May that Chrome 41 and up had them too, but IE and Safari didn’t. The rest parameters and defaults article says Firefox has supported both since version 15 and that, as of May 21, no other released browser supported either one. A week later Mozilla wrote that Firefox had most of destructuring but not all of it, and that Chrome support was still being developed.
Microsoft Edge is still a preview that comes with Windows 10 test builds, and the Chakra team’s May post on the Microsoft Edge blog says most ES2015 features in the Windows 10 preview are on by default, including arrow functions, template strings, rest parameters, spread, and let and const. Default parameters and generators sit behind the experimental JavaScript flag, and classes moved behind it too because the spec changed late in the process.
Modules are a different story, and it’s not really the browsers’ fault. ES2015 defines the syntax and how modules link together, but the operation that turns './prices.js' into an actual loaded module, HostResolveImportedModule, is left to the host environment. For browsers that job belongs to the WHATWG Loader spec, which describes itself as a work in progress. So there’s no standard way yet for a page to load an ES2015 module directly.
Transpilers fill the gap
Almost nobody gets to target only the newest browsers, so the practical way to use all of this today is a transpiler that turns ES2015 into ES5 before you deploy. Microsoft’s post names TypeScript, Babel and Traceur, and Mozilla’s ES6 In Depth articles keep pointing readers to Babel and Traceur. Babel is on the 5.x line, and 5.6.0 was tagged on June 20. Its docs show a global install and a directory build:
npm install --global babel
babel src --out-dir lib
For modules, Babel rewrites import and export into another module system. According to its modules documentation, CommonJS is the default, with AMD, System and UMD available through the --modules option. From there you still need something to get those modules into a page, and the Loader draft itself names Browserify, WebPack and jspm as the kind of front end packaging tools it wants to let hook into loading.
Syntax is only half the job. New built-ins like Promise don’t exist in older browsers no matter how you compile your code, so Babel ships a polyfill based on core-js that you load before your compiled code. Babel’s caveats page lists a few more things to know: array destructuring and for...of need Symbol, built-in classes like Array and Date can’t be properly subclassed when compiled to ES5, and the output assumes an ES5 environment, so very old IE also needs an ES5 shim.
The takeaway
ES2015 being official doesn’t mean you can send it straight to every browser tomorrow. What it does mean is that the syntax is settled, so code you write with let, arrows, destructuring, classes, modules and Promises today is written against a real standard rather than a draft that might shift under you. A transpiler and a polyfill cover the gap for now, and as browser support fills in, those pieces can shrink and eventually go away.