Performance of JavaScript optional chaining
One of the coolest features added in just announced TypeScript 3.7 is optional chaining syntax. It promises a much shorter and more readable code for dealing with deeply nested data structures. How may this nice new feature affect the performance of your project?
At first sight, optional chaining syntax can make the codebase significantly smaller. Instead of writing monstrous code like this one:
foo && foo.bar && foo.bar.baz && foo.bar.baz.qux
you can write this
foo?.bar?.baz?.qux;
19 characters instead of 48. Quite concise!
Bundle size #
The thing is, it’s very unlikely that you’ll ship the new syntax to the end-user. At the time of writing the post, the only browser supporting it is Chrome 80. So, at least for now the transpilation is must-have.
How does the expression above look in plain old JavaScript?
var _a, _b, _c;
(_c = (_b = (_a = foo) === null || _a === void 0 ? void 0 : _a.bar) === null || _b === void 0 ? void 0 : _b.baz) === null || _c === void 0 ? void 0 : _c.qux;
That’s, well, far more than 19 characters, even more than 48 you could have before. To be precise, it’s 172 characters! Minification decreases this number, but it’s still 128 - 6 times more when compared with the source code.
var _a,_b,_c;null===(_c=null===(_b=null===(_a=foo)||void 0===_a?void 0:_a.bar)||void 0===_b?void 0:_b.baz)||void 0===_c||_c.qux;
Fortunately, the TypeScript compiler isn’t the only option we have. Babel provides support for optional chaining as well.
Let’s check how it deals with the new syntax. Is it any better than TypeScript? It doesn’t look like! 244 characters.
var _foo, _foo$bar, _foo$bar$baz;
(_foo = foo) === null || _foo === void 0 ? void 0 : (_foo$bar = _foo.bar) === null || _foo$bar === void 0 ? void 0 : (_foo$bar$baz = _foo$bar.baz) === null || _foo$bar$baz === void 0 ? void 0 : _foo$bar$baz.qux;
However, after running Terser on the code, the code is smaller than minified TypeScript output - 82 characters.
var l,n;null==u||null===(l=u.bar)||void 0===l||null===(n=l.baz)||void 0===n||n.qux
So in the best scenario, we’re getting around 4 characters in the final bundle for each one of the source code. How many times could you use optional chaining in a medium-sized project? 100 times? If you migrate to the new syntax in such a case, you’ll just add 3.5 kB to the final bundle. That sucks.
Alternatives #
Let’s take a step back. Optional chaining isn’t a new idea at all. Solutions for the incredibly && long && double &&
ampersands && chains
problem have already existed in the so-called userspace for quite some time. Jason Miller’s
dlv
is only one among many.
dlv(foo, 'bar.baz.qux');
Besides this approach isn’t as good as the new syntax, because it’s not type-safe, it requires slightly more code on the call site - 25 characters. Plus, you must import the function from the library. But, how does the code look in the final bundle?
d(u,'bar.baz.qux');
What a surprise! 19 characters, that’s as concise as optional chaining syntax itself.
If you feel uncomfortable with strings, you can pass an array of strings to the function. Although, there’s more characters in both source and the final code, it may be worth doing. You will see later why.
dlv(foo, ['bar', 'baz', 'qux']);
Implementation of the function itself takes only 101 characters after minification.
function d(n,t,o,i,l){for(t=t.split?t.split("."):t,i=0;i<t.length;i++)n=n?n[t[i]]:l;return n===l?o:n}
It means it’s enough to use optional chaining transpiled with Babel twice and you’ll get more code than with dlv
.
So, is the new syntax no-go?
Parsing time #
The amount of the code affects not only downloading a file but also the time of parsing it. With estimo, we can estimate (😉) that value. Here are the median results of running the tool around 1000 times for all variants, each containing 100 equal optional chainings.
It seems that parsing time depends not only on the size of the code but also on the syntax used. Relatively big “old spice” variant gets significantly lower time than all the rest, even the smallest one (native optional chaining).
But that’s only a curiosity. As you can see, at this scale differences are negligible. All variants are parsed in time below 2 ms. It happens at most once per page load, so in practice that’s a free operation. If your project contains much more optional chaining occurrences, like ten thousand, or you run the code on very slow devices - it might matter. Otherwise, well, it’s probably not worth bothering about.
Runtime performance #
Performance is not only about the bundle size, though! How fast is optional chaining when it goes to execution? The
answer is: it’s incredibly fast. Using the new syntax, even transpiled to ES5 code, may give 30x (!) speedup comparing
to dlv
. If you use an array instead of a string, though, it’s only 6x.
No matter whether you access empty object, full one or one with null inside, approaches not employing accessor function are far more performant.
Conclusion #
So, is optional chaining fast or slow? The answer is clear and not surprising: it depends. Do you need 150 M operations per second in your app, or 25 M is enough? Could the slower implementation decrease FPS below 60? Does it make sense to fight against these few kilobytes coming from transpilation? Is it possible that the loading time of the app increases significantly because of them?
You have all the data now, you can decide.