Exciting ES2015 Features
Last updated
Internet Explorer 8 was released 6 years ago. It's the last browser with a nontrivial market share that is stuck on ES3.
Surprisingly, the biggest complaint that I hear about JavaScript on IE8 is having to deal with trailing commas in object
literals. This is a solved problem. ES5 brings a whole lot more to the table than
giving the syntax some slack. Getters/setters/properties were introduced in ES5, but I'm hard pressed to find any
libraries which use them to their advantage.
Object.freeze()
,
Object.seal()
, and
Object.preventExtensions()
are extremely useful defensive mechanisms which help programs enforce their invariants. Using these new(ish) ES5
features could have huge impacts on how we read and write JavaScript. So where are they?
Wait, I thought this was about ES2015 (ES6).
I'm afraid that the features that are getting the most attention in ES2015 are similar to that trailing comma. This new version gives us much more than light syntactic sugar (as opposed to heavy syntactic sugar, like Haskell's do notation). ES2015 gives us tools that can (and hopefully will) change how JavaScript is written and read. Let's dive in.
Map & Set
Here's a bold claim: the majority of JS written today uses pointers. Yes, pointers: like in C. I know this sounds a bit
crazy, but bear with me. This is because an Object
cannot have keys which are themselves Object
s.
Our libraries and frameworks have been so engrained with this limitation that it might not even seem useful! But if we look at the APIs which we call today, we call functions which return "unique" primitives to refer to a handle and we write functions which take these primitives and return the objects they refer to.
In other words, we allocate things that live elsewhere and are given a primitive string or number which we later dereference in order to get at its underlying meaning. These are the fundamental pointer operations. And just like in C, bad things happen when these "pointers" are passed to functions which expect pointers of a different type, when they are pre-calculated, or when they refer to objects which have since been deleted.
This behavior is so pervasive that we don't bat an eye at documentation assuming identifiers always start with the value 1 or demonstrate that they can be referenced by literals.
Why is this? Ultimately, the only tool we have to efficiently create an association between an object and external
metadata is the built-in Object
, which can only take strings as its keys.
ES2015's Map
allows us to create an association between an arbitrary Object
and arbitrary data. It lets us do this
without resorting to
dirty tricks which either won't work
if we use
Object.freeze()
or
require searching through arrays of key-value pairs to find an identity match.
So let's look at an example. Imagine a different implementation of setTimeout
and clearTimeout
, which uses Map
internally to verify that only setTimeout
handles are passed to clearTimeout
:
var timers = (function () {
var sentinel = {};
var handleMap = new Map();
return {
setTimeout: function (func, ms) {
var handle = window.setTimeout(func, ms);
var safeHandle = Object.create(sentinel);
// safeHandle's prototype is sentinel
handleMap.set(safeHandle, handle);
return handle;
},
clearTimeout: function (safeHandle) {
if (Object.getPrototypeOf(safeHandle) !== sentinel) {
// safeHandle is not from setTimeout
throw new TypeError('clearTimeout handle not created by setTimeout');
}
var handle = handleMap.get(safeHandle);
if (handle === undefined) {
// safeHandle no longer valid
throw new Error('clearTimeout called with unknown handle');
}
handleMap.delete(safeHandle);
return window.clearTimeout(handle);
}
};
})();
This example clearTimeout
can only be called with values returned from setTimeout
exactly once. By adding an extra
layer of protection, we can ensure that our API is used as designed and future results cannot be "predicted". If the
same approach is taken for requestAnimationFrame
and clearAnimationFrame
, an attempt to call
clearAnimationFrame(setTimeout(func, ms))
would result in a TypeError
exception. This makes our contracts and
invariants explicit. If we bake the limits of our software's design
into our APIs, there are fewer opportunities to surface bugs. On the other hand, if we execute
clearAnimationFrame(setTimeout(func, ms))
, we'd be sad to discover that the callback func
is called after ms
milliseconds and there are no exceptions raised or errors logged.
With Map
and Set
, we can write APIs which prevent the construction of references and associate external data with an
object without modifying it. We can move away from the primitive obsession
forced upon us by ES5 and before.
WeakMap
This brings us to our next related feature: the ability to store associations in a memory-efficient way.
Let's say we want to associate data with an arbitrary object. We could use a Map
where the keys are the object we
want to add associated data to, and the values would be the associated data. If we use this, we can add associated
data without modifying the original object, but we'll need to be aware of when the object is no longer referenced in
order to .remove()
it from the map so that our program doesn't leak memory.
It's a bad practice to mutate objects that we don't own, But in ES5, when we want to associate data with an object that we don't own and don't want to mutate (and doesn't have a disposal notification mechanism), we're out of luck: we'll either have to mutate the object or will be forced to leak memory.
What can we do here? One of JavaScript's design trade-offs is that it doesn't tell us when objects are freed. It would
be amazing if we could be notified that an object is completely unused so we could remove it from our Map
. But we
just can't know when data is dereferenced unless we build a reference counter, track all of our objects' references, and
build a subscription notification mechanism when our objects are fully dereferenced. This is a pain, it'd be building
our own garbage collector.
Thankfully, there's another way! WeakMap
is a new interface which lets us associate without having to leak memory,
mutate objects, or use our own home-grown garbage collector.
This can be a bit abstract, so let's go through a simple example:
A Twitter clone has Tweet
and User
models. We need to create an association of Tweet
to an array of User
s which
represents the users who have favorited the Tweet
. How can we do this without adding a new field to the Tweet
model?
In ES5, we'd probably end up creating a new TweetFavorites
interface which held a mapping between Tweet
and a set of Users. It'll probably look like this:
function TweetFavorites() {
this.tweetUsers = {};
}
TweetFavorites.prototype.getFavoriteUsers = function (tweet) {
if (!this.tweetUsers[tweet.id]) {
this.tweetUsers[tweet.id] = [];
}
return this.tweetUsers[tweet.id];
}
If we use ES2015's Map
, we can remove the string .id
:
function TweetFavorites() {
this.tweetUsers = new Map();
}
TweetFavorites.prototype.getFavoriteUsers = function (tweet) {
if (!this.tweetUsers.has(tweet)) {
this.tweetUsers.set(tweet, []);
}
return this.tweetUsers.get(tweet);
}
This code has the same problem we just described. Each item we add to the array returned from .getFavoriteUsers()
is
now (and forever) strongly referenced by this TweetFavorites
interface. If the rest of the application decides to
remove all references to a specific Tweet
, our TweetFavorites
implementation would still hold on to the Tweet
as a
key in its internal Map
and all of the items in its associated array. Without any references remaining, we leak memory
for the duration of the application.
To solve this, we would need to manually delete the Tweet
's favorites array within the TweetFavorites
implementation
when a Tweet is "disposed" and ready to be removed from the rest of the application. This might look something like
this:
function TweetFavorites() {
this.tweetUsers = new Map();
}
TweetFavorites.prototype.getFavoriteUsers = function (tweet) {
if (!this.tweetUsers.has(tweet)) {
this.tweetUsers.set(tweet) = [];
tweet.onDispose(function () {
this.tweetUsers.delete(tweet);
}.bind(this));
}
return this.tweetUsers.get(tweet);
}
But if the Tweet
interface did not have an .onDispose()
subscription, we'd be out of luck.
But we can use a WeakMap
! A WeakMap
is just like a Map
except it weakly holds on to its keys. When the garbage
collector comes around looking for unreferenced objects, it treats the keys in our WeakMap
as if they are not there.
If one of the WeakMap
's Tweet
keys has all of its references removed, it's as if .delete()
was called, and is
allowed to be garbage collected.
Here's what our TweetFavorites
implementation would look like with WeakMap
:
function TweetFavorites() {
this.tweetUsers = new WeakMap();
}
TweetFavorites.prototype.getFavoriteUsers = function (tweet) {
if (!this.tweetUsers.has((tweet)) {
this.tweetUsers.set(tweet, []);
}
return this.tweetUsers.get(tweet);
}
It's the same as the naive Map
, but it is no longer susceptible to leaking memory!
One other notable use case for WeakMap
is to have true private values for our objects in a memory-efficient way.
Here's a demonstration of what this would look like:
var MyClass = (function () {
var PrivateData = new WeakSet();
function MyClass(val) {
var privates = {};
PrivateData.set(this, privates);
privates.value = val;
privates.secret = "you can't read this!";
}
MyClass.prototype.getValue = function () {
var privates = PrivateData.get(this);
return privates.value;
}
return MyClass;
)();
No matter how hard we try (aside from using a debugger, overriding the WeakMap.prototype
methods, or calling
toString
on the function), an outside observer will not be able to access our private data! And unlike Crockford's
suggestion of how to create true privates with closures, the function
implementations all live on the prototype, so this approach is much more memory-efficient.
WeakSet
Similar to WeakMap
is WeakSet
, which is great for "tagging" objects that may eventually go away (like keeping track
of Tweets
that we have viewed in our session so we can display them differently). It works like this:
function Tweet(name) {/*...*/}
var viewedTweets = new WeakSet();
var tweet = new Tweet('@alice', 'Hello, world!');
viewedTweets.has(tweet); // false, we haven't added her yet
viewedTweets.add(tweet);
viewedTweets.has(tweet); // true, we just added her
viewedTweets.delete(tweet);
viewedTweets.has(tweet); // false, we just removed her
viewedTweets.add(tweet);
tweet = null; // @alice's Tweet is now unreachable
// @alice's tweet is no longer held within viewedTweets
// ...but we can't programmatically verify this.
We can add an item to the set and check if items are in the set. If an item is held weakly in the WeakSet
, but is
dereferenced everywhere else and unreachable (which means we can't check if it is present in the set), the
garbage collector will also remove it from the set for us. A WeakSet
is identical to a WeakMap
where the values
are all true
(or null
or false
or any other single value).
WeakMap
and WeakSet
make it easier for us to build small and low-coupled components without having to worry about
object lifecycle clean up.
Feature Request: WeakRef
Unfortunately, there's one missing piece here. Keeping in line with the design trade-off of JavaScript preventing us
from observing object lifecycle events, ES2015 is missing a WeakRef
: a weakly held reference to an object that we can
obtain if it hasn't been garbage collected.
With weak references, we could label our objects properties with explicit ownership semantics (like how we tag members
as strong
or weak
in Objective-C @property
declarations) and rely on the garbage collector (or something like ARC)
to keep our memory footprint efficient.
It's important to note that this isn't a radical request. Weak references are present in practically every language which has a garbage collector or some form of automated memory management: C#, C++, Haskell, Java, Lua, Objective-C, OCaml, PHP, Perl, Python, R, Ruby.
Generators
I've previously written about generators and why they are a game changer here. Go ahead and take a read, it's worth it.
Proxy
Despite having incredibly good introspection facilities, we are still limited in what we could simulate with
user-defined objects. Proxy
lets us create objects with powers that only browser vendors had access to.
One of the surprisingly complex things that jQuery does is make each jQuery object act like an array-like value. It
makes $('.foo')[0]
act like $('.foo').get(0)
. The internal
jQuery.merge
function is responsible
for maintaining this array-like behavior.
But with Proxy
, we can create array-like objects without having to define every single index. We can react to the
internal
[[get]]
method by creating a Proxy
on an object which declares the get
action.
function InfiniteDoubleArray(length) {
return new Proxy({
length: Infinity
}, {
get: function (target, name) {
if (name in target) {
return target[name];
}
var maybeIndex = parseInt(name, 10);
if (!isNaN(maybeIndex)) {
return 2 * maybeIndex;
}
return undefined;
}
});
}
var doubleArray = new InfiniteDoubleArray();
console.log(doubleArray[3]); // --> 6
console.log(doubleArray[256]); // --> 512
console.log(doubleArray[1234321]); // --> 2468642
Though this example is contrived, Proxy
allows us to dynamically react to properties or methods which may or may not
truly exist on the underlying object. This is just like the what we have in other languages:
Python's __getattr__()
method,
PHP's __get()
magic method,
Ruby's method_missing
function,
Objective-C's message forwarding mechanism, etc, etc.
We're not limited to just retrieving values or calling functions, but we can dynamically react to any of the fundamental operations that can be performed on an object. We can now change the following fundamental operations:
getPrototypeOf
: respond when accessing an object's prototype (explicitly and implicitly)setPrototypeOf
: respond when attempting to set an object's prototypeisExtensible
: respond when new properties are checked if they can be added to an objectpreventExtensions
: respond whenObject.preventExtensions(target)
is called on the target objectgetOwnPropertyDescriptor
: respond to generic property accessdefineProperty
: respond whenObject.defineProperty(target, prop)
is called on the target objecthas
: change the behavior of thein
operatorget
: change the behavior of property readsset
: change the behavior of property writesdelete
: change the behavior of thedelete
keywordenumerate
: provide an iterator for an objectownKeys
: respond whenObject.keys(target)
is called on the target objectapply
: provide behavior when an object is called as a functionconstruct
: provide behavior when an object is called with thenew
keyword
In short, Proxy
allows us to write APIs as if we had the same powers as browser implementors. If we wanted to, we could use
this to perfectly simulate the document.cookie
API, or the behavior of complex, native HTML elements, like how the value of
Node.prototype.innerText
performs whitespace
normalization in certain cases as well as depends on the
calculated layout of the element.
Reflect
As a counterpoint to Proxy
, Reflect
gives us the ability to trigger these same fundamental operations as function
calls. This is mostly helpful for writing introspective code in a functional style. Here's how it works:
// construct
Reflect.construct(MyClass, [arg1, arg2]);
// functionally equivalent to:
new MyClass(arg1, arg2);
// deleteProperty
Reflect.deleteProperty(obj, 'key');
// functionally equivalent to:
delete obj.key;
// get
Reflect.get(obj, 'key');
// functionally equivalent to:
obj.key;
// set
Reflect.set(obj, 'key', value);
// functionally equivalent to:
obj.key = value;
// has
Reflect.has(obj, 'key');
// functionally equivalent to:
'key' in obj;
Take a look at the full set of Reflect
operations in MDN's
documentation.
const and Real Immutability
const
declarations give us immutable references, but the objects it refers to is still able to change contents.
Object.freeze()
and a handy deepFreeze
wrapper gives
us immutable objects. Together, we can have real immutability.
// To make obj fully immutable, freeze each object in obj.
// To do so, we use this function.
function deepFreeze(obj) {
// Retrieve the property names defined on obj
var propNames = Object.getOwnPropertyNames(obj);
// Freeze properties before freezing self
propNames.forEach(function(name) {
var prop = obj[name];
// Freeze prop if it is an object
if (typeof prop == 'object' && prop !== null && !Object.isFrozen(prop))
deepFreeze(prop);
});
// Freeze self
return Object.freeze(obj);
}
const myObject = deepFreeze(anything);
// myObject will now always refer to a truly immutable object
// we can always assume it and its contents will never change
This deepFreeze example by Mozilla Contributors is licensed under CC-BY-SA 2.5.
Better Unicode Support
Ever wondered why '💩'.length === 2
? It's because ECMAScript decided to use UTF-16 code units as its internal character
encoding. In other words, JavaScript has a Unicode problem.
Mathias says it best in his article about unicode-aware regex.
The bad news is that there still isn't a property on String which returns the number of Unicode code points.
The good news is that we will have the ability to read and create Unicode code points using
String.prototype.codePointAt()
and String.fromCodePoint()
! Unfortunately, there's a really unfortunate wart in
the design of String.prototype.codePointAt()
, which means we need to be careful:
// Get a simple character from a string:
'x💩x'.codePointAt(0) === 0x78;
// 0x78 is the Unicode code point for the letter 'x'
// Get a complex character from a string:
'x💩x'.codePointAt(1) === 0x1F4A9;
// 0x1F4A9 is the Unicode code point for '💩'
// WARNING: the following does not make much sense
'x💩x'.codePointAt(2) === 0xDCA9;
// 0xDCA9 is the *second surrogate pair* of the
// UTF-16 code unit pair for '💩'
// Get a simple character from a string:
'x💩x'.codePointAt(3) === 0x78
// 0x78 is the Unicode code point for the letter 'x'
Though things have gotten slightly better, we still must be very careful when dealing with individual "characters" in strings, especially when performing validation on user input.
Conclusion
ES2015 has the opportunity to change how we write JavaScript. Unfortunately, it is impossible for most of these benefits to be achieved with the use of ES2015 to ES5 compilers like Babel or Closure Compiler.
I can't wait until the day we live in a world where we can ship raw ES2015 source code to be executed natively on the browsers we support.
Lastly, a big thanks to Daniel Espeset and Dan Na for reviewing a draft of this and providing valuable feedback.