NeverSawUs

Vague, Meandering Impressions of ES6

Over the last 3 or 4 days, I've been poking at a toy streams implementation to scratch an itch. Doing so has afforded me a wonderful opportunity to work in ES6; which is something I haven't really had a lot of occasion to do with my other projects. I mentioned on twitter that I'm generally bullish on the additions made to the language, but that the community will have to revisit a lot of best practices. What follows is a journal of my impressions in longer form.

Classes

Classes are a sane, largely inoffensive addition. It's a small thing to note, but I expected to like the syntax less than I did. I was weary of inlining methods into the class body: it felt like it would be hard to scan, and I definitely didn't appreciate the extra level of indentation. In practice, though, it was no big deal. Once methods reached a certain size it was trivial to use a hoisted function declaration to provide the implementation so it was still easy to "scan" the class for its methods.

// YAWN.
class X {
  method(a, b) {
    methodImpl(this, a, b);
  }
}

function methodImpl(obj, a, b) {
}

At worst, I suspect we'll see a lot more traditionally OO-y-looking APIs briefly blossom, then quickly wither.

Class syntax paves a cowpath. There's no drastic reimagining of JS at play here. The new syntax simply makes the existing process of writing constructor functions and linking prototypes less prone to error, and more pleasant to read and write. In using them I didn't feel that they had changed any of my (deeply and violently held, 'natch) opinions about JavaScript API design.

Import syntax

Speaking of features I thought would be contentious which ended up innocuous: the import syntax! Apart from having to write export default (which still feels a bit clunky), the syntax faded into the background. I worry about explaining the reasoning behind default to newcomers, but it's no more arbitrary than the exports / module.exports dichotomy that already exists.

// an example of a typical module:
// 1. import things
// 2. export a class
// 3. method implementations and private functions below the export.
// 4. YAWN
import X from "../relative/path.js";

export default class XYZ {
  constructor() {
  }
  method(arg1, arg2) {
    return applyMethod(this, arg1, arg2)
  }
}

function applyMethod(xyz, arg1, arg2) {

}

The syntax was less onerous than I had initially imagined, and worked out of the box using 6to5 as-expected. So, nothing really to report on that front.

Arrow functions

To my surprise, I found myself replacing function expressions with arrow functions. Historically I've been critical of this feature. I don't like that they add another flavor of function to the mix; nor do I like that they throw an exception when instantiated with new.

However, in practice they are a much sweeter syntax for anonymous functions; and its fairly easy to ignore their other implications when working in a self-contained codebase. I am still concerned — only time will tell if their introduction causes confusion or bugs — but they're definitely refreshing to use.

From an aesthetic perspective, I appreciate that arrow function's pared-down syntax: it more accurately conveys their use than the heavyweight-by-comparison function expression. For bigger, "more serious" functions, I still found myself making use of hoisted, named functions. When the internal workings of an inner function are important to the proper understanding of a surrounding function, arrow functions shine. When the inner function's implementation is unnecessary or deleterious to understanding the outer function, I prefer to use hoisted, named functions. In that respect, arrow functions do a much better job of telegraphing intent than function expressions.

// example use of arrow function: the implementation of the
// callback passed to "commit" is key to understanding the 
// flow of "write".
function write(item) {
  var digested = false;
  var sync = true;
  var called = 0;
  commit(item, (err) => {
    if (++called > 1) {
      err = new Error('called multiple times');
    }
    if (err) {
      return onerror(err);
    }
    if (!sync) resume();
    else digested = true;
  });
  sync = false;
  return digested;
}

// example use of named functions: the flow of pipeTo is
// more important to take in at a glance than the implementation
// of the inner functions.
function pipeTo(src, dst) {
  dst.onstart.add(onStart);
  src.onreadable.add(onReadable);
  src.onerror.add(onSrcError);
  dst.onerror.add(onDstError);

  function onStart() {
    // handwaving
  }
  function onReadable() {
    // handwaving
  }
  function onSrcError() {
    cleanup();
  }
  function onDstError() {
    cleanup();
  }
  function cleanup() {
  }
}

Your mileage may vary, of course; but I found myself using them wherever the contents of the function were more important than the fact that they were contained by a function. There's also the subtle implication that APIs predicated around passing important values as the this context of the function will go by the wayside, to which I can only cheer softly to myself from the sidelines.

Symbols and Computed Properties

Symbols, WeakMaps, and computed properties open up new ways of information hiding in JavaScript. Destructuring makes a lot of previously untenable APIs more palatable. Together, I'm fairly certain they'll greatly change the face of API design in JS.

// readable.js                              // writable.js                        // symbols.js
import {read, write} from "./symbols.js";   import {write} from "./symbols.js";   var read = Symbol('read');
class Readable {                            class Writable {                      var write = Symbol('Write');
  [read]() {                                  [write](chunk, cb) {                export {read, write};
    // read impl                                // write impl
  }                                           }
}                                           }

// pump can see both "read" and
// "write"; other modules can't
// see those methods.
function pump(src, dst) {
  do {
    if (dst[write](chunk)) {
      return;
    }
    src[read]()
  } while(readable);
}

The above three files illustrate using symbols and computed properties to let data types "collude" — that is, Readable and Writable can see each others read and write properties, but the world at large cannot. This is just the germ of an idea at this point, but the idea of letting modules agree on a "semi-private" API, shared between a couple of files or packages through the use of symbols, opens up new venues for controlling access to information. In the past, the only effective approach was the revealing module pattern, which was a bit of an all-or-nothing affair.

Destructuring (and Symbols and Computed Properties)

Destructuring enables APIs that would previously be clumsy to use: for instance, an "observable" library that returns a tuple of "emit function" and "listener interface" can be represented via destructuring and symbols like so:

// --- readable.js ------------------------------------
var readableSymbol = Symbol('readable');
class Readable {
  constructor() {
    // createTopic returns [sendEvent(...args), {addListener(fn), removeListener(fn)}].
    [this[readableSymbol], onreadable] = createTopic();
  }
}

// functions in this module can emit data events
// against any readableStream instance because they
// have access to the `readableSymbol`:
function ondata(readableStream, chunk) {
  readableStream[readableSymbol](chunk);
}

// --- file.js ----------------------------------------
var rs = new Readable;

// there's no way to "synthesize" an event from outside of
// the "readable.js" module!
rs.onreadable.addListener((chunk) => {
  console.log('hello!');
});
rs.onreadable.removeListener(function somefunction() {
  // some function!
});

Without destructuring, createTopic() would be an awkward API. With symbols, we can scope access to certain properties on objects to a given module. With symbols and destructuring, we can simultaneously create public and private properties in a single assignment.

In summary...

ES6 makes JavaScript markedly more comfortable to write and allows for some interesting new approaches to API design. I worry a bit about whether the additions lend themselves to readability: time and distancing oneself from the problem are prerequisite to evaluating readability, and I haven't had enough of either of those to say one way or another. The community at large should revisit its notions of privacy with respects to access control. We have new, potentially more effective tools with which to create encapsulation, but little prior art for their application. Experimentation and discussion is in order; but I'm generally optimistic about the additions at present.

As always, I'd love to hear what you think. Hit me up on twitter if you have thoughts / want to share your ES6 experiences!