At some point, JavaScript got good

Date
JavaScript Logo

I’ve been both fortunate and unfortunate to have worked with JavaScript full-time since about 2012. It was “unfortunate” because prior to around 2015—when major improvements started coming to the ECMAScript spec—the language was a real pain to wrangle on a daily basis.

However, I’m also very fortunate because while there’s been a lot of improvements made to JavaScript since then, the fundamental way the language works is still the same, so having a deeper understanding of the new syntax (sans sugar) is useful when it comes to debugging, working with legacy projects, or digging deeper into lower-level code. I’m also able to better appreciate just how good us JS devs have it these days.

Short background

I started working with JavaScript professionally back when I worked for a small startup called Lanica back in 2012. It was an Appcelerator-funded company that made a mobile game engine for the Titanium SDK (we worked out of the Appcelerator offices in Mountain View, CA).

Coming from Lua, which is a simple but very “sane” scripting language (that I used heavily at a prior company), JavaScript was quite the nightmare to learn (well) and use. I later moved onto working on traditional web frontends in JavaScript (anyone remember Backbone.js?) and backend development with Node.js.

The things I disliked most were function-scoped variables, caller context, callback hell, and writing “function() function() function()…” (everywhere!). All of these things combined made it frustrating to not only write JavaScript code, but to read as well.

Function vs. block scoped variables

The let and const keywords have all but completely replaced var for defining variables. Aside from the obvious benefit of knowing whether a variable is mutable or not, a lesser-known benefit is that variables defined with let and const are block-scoped as opposed to function-scoped (var), which helps prevent a lot of subtle bugs from creeping into your code.

We used to have to fake it with self-executing anonymous functions:

function example() {
  // without block-scoping:
  for (var i = 0; i < list.length; i += 1) {
    var x = list[i];

    // x looks like it is block-scoped but it's NOT!
  }

  // although defined in the loop's block above,
  // x is accessible anywhere in the function
  console.log(x);
  console.log(i); // the iterator is also available here

  // block scoping could be "faked" with a
  // self-executing anonymous function:
  for (i = 0; i < list.length; i += 1) {
    (function() {
      var y = list[i];
      
      // y is only accessible within this function
    })();
  }
}

The second loop where block-scope is “faked” works, but it’s much less readable and more error-prone than true block-scoping (more on this a little further). JavaScript codebases were littered with stuff like this all over the place to work around the quirks of the language.

Function context (this)

These days, if you’re using the this keyword, you’re likely writing code for a class method referencing the current instance (similar to other object-oriented languages). In the previous era of JavaScript, you had to be keenly aware of a thing called “function context” (also referred to as “this” context) and what this refers to could differ based on how the function is called.

I wouldn’t be surprised if JavaScript developers who learned the language post-ES6 are unaware of what function context is, because it’s not as directly used as much as before.

In short, JavaScript has a “context” for each function call that is mapped to the this keyword. Remember the previous code example where we defined an anonymous function that called itself? It would cause issues if we tried to access the outer function’s this keyword within that function:

function example() {
  console.log("1:", this.someVar);   // 1: "hello"

  (function() {
    console.log("2:", this.someVar); // 2: undefined
  })();
}

example.call({ someVar: "hello" });

Because each function has their own caller context, the inner function doesn’t inherit the outer function’s context so this doesn’t refer to the same thing. You would need to store the outer function’s this value in a separate variable, or use call() or apply() to explicitly set the inner function’s context (thisArg).

ECMAScript 2015 (ES6) introduced “arrow functions”, which on the surface looks like a way to save us from having to type “function” all the time (and it does), but there’s more to it than that. These arrow functions also inherit the context from the parent scope at the time they are defined. So this example works as you would expect:

function example() {
  console.log("1:", this.someVar);   // 1: "hello"

  (() => {
    console.log("2:", this.someVar); // 2: "hello"
  })();
}

example.call({ someVar: "hello" });

I can’t remember the last time I had to use call() or apply() in modern JavaScript codebases, or had to fix a bug because this wasn’t what it appeared to be. But I remember a time when I had to be painfully aware of function caller context at all times. If I never have to use call() and apply() ever again, I’d be okay with that.

Callback hell

Before promises and async/await were built-in, there was callback hell. If you haven’t had the pleasure of working with JavaScript in those days, consider yourself lucky. For example:

function getResults(callback) {
  makeFirstRequest(function(result1, err1) {
    if (err1) {
      callback(null, err1);
      return;
    }

    makeSecondRequest(function(result2, err2) {
      if (err2) {
        callback(null, err2);
        return;
      }

      makeThirdRequest(function(result3, err3) {
        if (err3) {
          callback(null, err3);
          return;
        }

        // return result via top-level callback argument
        callback({
          result1: result1,
          result2: result2,
          result3: result3,
        }, null);
      })
    });
  });
}

getResults(function(result, err) {
  if (err) {
    console.log(err.message);
    return;
  }
  console.log("Got results:", results);
});

The code above is just to illustrate the concept, so there’s certainly cleaner ways to write it, but no matter how much lipstick you put on that pig, it’s still painful to look at (let alone write).

The example calls a function (getResults()) that makes three requests, combines the results and “returns” an object (or an error) via a callback function. This (or some variation of it) is how asynchronous code was handled in JavaScript, and is known as “callback hell” because the labrynth of nested callbacks (just imagine if each nested function was larger and more complex).

Contrast the above with how easy we have it today with promises and async/await (along with object shorthand syntax and error handling):

async function getResults() {
  const result1 = await makeFirstRequest()
  const result1 = await makeSecondRequest();
  const result3 = await makeThirdRequest();

  return {
    result1,
    result2,
    result3,
  };
}

try {
  const results = await getResults();
  console.log("Got results:", results);
} catch (err) {
  console.log(err.message);
}

The asynchronous nature of Node.js is where callback hell was really prevalent, but it was not uncommon to run into this on the frontend as well (ie. making network requests).

Towards the future

After the big “boost” that JavaScript got with ECMAScript 2015 (ES6), there have been continuous improvements to the language and it just keeps getting better. Because of the wide range of implementations, the use of transpilers has become standard in the community, so we can all take advantage of the newest JavaScript features on a per-project basis, without worrying (too much) about what each individual JS engine supports.

Later versions of JavaScript didn’t remove any of the “warts” that are often associated with the language. You can still write code in exactly the same way as before (if you’re a masochist), but newer features added better ways to write code that effectively make the old way of doing things obsolete (although there are still some oddities to be aware of). Combined with good linting tools such as ESLint and (more recently) Biome.js, the JavaScript of today is almost unrecognizable from what it used to be.

In hindsight, it’s actually pretty amazing how the language was able to evolve so much while maintaining near complete backwards compatibility with prior language specs. There’s still a lot of disdain for JavaScript in the wider programming community, but I think it’s because people were scarred from the JavaScript of yesterday (and I don’t blame them!). But I think if it were judged based on the language it is today, many people would see that it’s actually pretty nice now.

I’ve since gone “all in” on TypeScript, but as a superset, at the end of the day it’s still “just JavaScript” (with types). I personally wouldn’t start a new JavaScript project without it these days, but that’s a topic for another day.

UPDATE: Remember when I said “there’s still a lot of disdain for JavaScript in the wider programming community”? See the Hacker News thread to drive the point home. It may be an unpopular opinion (and there could be some Stockholm syndrome at play here), but personally I like it now.