A Half-Hour to Learn JavaScript

September 30, 2022 20 minute read

I’ve been writing JavaScript on and off for nine years at the time of this writing. And if you’ve been close to a browser, chances are that you have too.

You can pick up the language in a weekend and use it for years without grasping its deeper concepts. For a long time, I was confident that I knew it well. I was slinging functions left and write and believed to have conquered JavaScript.

But when an interviewer asked me to explain what a closure was, I was stunned. I intuitively understood it, but I couldn’t put my thoughts into words.

And have you really grasped something if you can’t explain it?

When I came home after the interview, I realized that I’d need to devote significant time to learning the fundamentals of JavaScript. I made a lot of notes along the way, and I go through them every time I decide to go to interviews.

And to make them easier to remember, I gave them a Dungeons & Dragons flavor.

Table of Contents

Scope

Scope is the visibility of functions and variables in the different parts of your code.

In other words, the scope of a variable or a function is the part of the code in which it is available. Working with variables and scope comes intuitively to most developers but knowing the rules will help you avoid some pitfalls.

In JavaScript, we have three types of scope - global, function, and block.

Every variable or function defined on a module’s root level is available everywhere inside of it. Before we had modules, a global variable would be available everywhere throughout your application, but now it’s only scoped to the module (file) it was defined in.

const name = 'Iymrith, the Giant'

function logGlobalName() {
  console.log(name)
}

console.log(name) // Iymrith, the Giant
logGlobalName() // Iymrith, the Giant

But variables can be shadowed between scopes. You can redefine one with the same name in a different scope, and it won’t cause an error, even if you use const.

const name = 'Iymrith, the Giant'

function logTrueName() {
  const name = 'Iymrith, the Blue Dragon'
  console.log(name)
}

console.log(name) // Iymrith, the Giant
logTrueName() // Iymrith, the Blue Dragon

In terms of software design, shadowing variables can be very confusing. So try to use a more descriptive variable name instead of redefining one.

You can create a global variable unintentionally as well. If you try to use a variable directly, without the var, let or const keyword, the compiler will look for its definition up to the global scope. If it doesn’t find it there, it will define it in the highest scope.

If you don’t have a linter and a proper IDE, a simple typo can result in another variable being defined and many hard-to-track bugs.

Function scope makes a variable only available inside the function it was created in.

function logName() {
  const name = 'Iymrith, the Blue Dragon'
}

console.log(name) // ReferenceError: name is not defined

The same goes for block scope.

if (true) {
  const name = 'Iymrith, the Blue Dragon'
}

console.log(name) // ReferenceError: name is not defined

Variable Keyword Scope

But scope is not just a matter of where you place the variable definition. It depends on the keyword that you use to define it. Variables defined with var are function-scoped, while those with let and const are block-scoped.

A variable declared with var is available even outside a block if it’s in the same function.

function logName() {
  if (true) {
    var name = 'Iymrith, the Blue Dragon'
  }

  console.log(name)
}

logName() // Iymrith, the Blue Dragon

But it won’t be available if the conditional statement doesn’t run.

function logName() {
  if (false) {
    var name = 'Iymrith, the Blue Dragon'
  }

  console.log(name)
}

logName() // undefined

Hoisting

Notice that we didn’t get a ReferenceError when we used var, but we got one when we used const. This is because of something called hoisting.

It’s the behavior that enables var variables to be available in the entire function and functions to be callable even before they’re defined.

logName() // Iymrith, the Blue Dragon

function logName() {
  const name = 'Iymrith, the Blue Dragon'
  console.log(name)
}

The simplest way to explain how hoisting works is to imagine that all variables declared with var and all functions declared with the function keyword are pulled to the top of their scope when they’re executed.

logName() // Iymrith, the Blue Dragon

function logName() {
  const name = 'Iymrith, the Blue Dragon'
  console.log(name)
}

// Turns into...

function logName() {
  const name = 'Iymrith, the Blue Dragon'
  console.log(name)
}

logName() // Iymrith, the Blue Dragon

When it comes to variables declared with var, if the value assignment is done in the middle of the function, it will stay there. But the definition of the variable will be pulled up.

function logName() {
  if (false) {
    var name = 'Iymrith, the Blue Dragon'
  }

  console.log(name)
}

name() // undefined

// Turns into...

function logName() {
  var name

  if (false) {
    name = 'Iymrith, the Blue Dragon'
  }

  console.log(name)
}

logName() // undefined

The tricky thing here is that if you assign a function as a value to a variable, again, only the variable definition will be hoisted.

logName() // TypeError: logName is not a function

var logName = function () {
  const name = 'Iymrith, the Blue Dragon'
  console.log(name)
}

// Turns into...

var logName

logName() // TypeError: logName is not a function

logName = function () {
  const name = 'Iymrith, the Blue Dragon'
  console.log(name)
}

Hoisting can lead to subtle problems like the one above, so as a rule of thumb I always declare my functions with the function keyword or I use arrow functions when I use an anonymous one.

We’ve used var for the hoisting examples because let and const do not hoist. The code below will work.

function logName() {
  console.log(name)
  var name = 'Iymrith, the Blue Dragon'
}

logName() // undefined

But this one will throw an error.

function logName() {
  console.log(name)
  const name = 'Iymrith, the Blue Dragon'
}

logName() // ReferenceError

And the same goes for variables declared with let.

Hoisting - Proper Explanation

Imagining that variables declared with var and function definitions are pulled to the top of the file is a good mental model when you’re trying to understand a piece of code. But that’s now how JavaScript really works.

The engine compiles JavaScript to machine code before executing it. And in the process of that compilation, it makes multiple runs through our code. In one of the early runs, it will declare all functions and variables.

So when the code is executed they are already defined.

Variable Declaration

Nowadays there is no objective reason to use var anymore. The fact that it hoists creates chellenging situations and a program that depends on that behavior would be harder to understand.

Default to using const when the variable’s value is not expected to change and let when you are going to reassign it or alter it. One behavior that we need to highlight is that arrays and objects defined with const can still be modified if the reference is not changed.

const character = {
  name: 'Drizzt',
}

character = {
  name: 'Drizzt',
  race: 'Drow',
}

// TypeError: Assignment to constant variable

But if we add a property to the existing object we won’t get an error.

const character = {
  name: 'Drizzt',
}

character.race = 'Drow'

Variables Software Design

The easiest thing to do would be to use let everywhere and not think twice about scope and reassignments. But the variable declarations are also a method of communication to the other engineers.

When I see a let definition I know that somewhere below this variable may get reassigned i.e. a conditional statement that modifies it. A const, on the other hand, communicates the opposite - the variable will remain the same, even if it’s contents change.

Values and References

We mentioned that the properties in an object defined with const can change because they don’t change the underlying reference. To understand what this means, we have to look at the JavaScript data types.

Every variable can be one of seven possible types - string, number, boolean, undefined, null, symbol, and object.

The first six are what you would call primitive types. Each variable that holds a primitive type, creates its own copy of it.

const strength = 16
let dexterity = strength

dexterity++

console.log(strength === dexterity) // false

When we pass a primitive value to a function its value gets copied and any modifications done inside the function won’t be reflected to the outside variable.

const strength = 16

function increaseStat(strength) {
  strength++
}

increaseStat(strength)

console.log(strength) // 16

Because of that we say that primitive types are passed by value. The contents of the variable are copied when we assign it to another variable or pass it to a function.

Objects and arrays, on the other hand, work differently.

const characterAStats = { strength: 16 }
const characterBStats = { strength: 16 }

console.log(characterAStats === characterBStats) // false

Even though they have the same contents, they are not equal. When we compare objects, we check if they point to the same underlying piece of memory. And these two hold the same data, but are stored in different places because they’re defined separately.

const characterAStats = { strength: 16 }
const characterBStats = characterAStats

console.log(characterAStats === characterBStats) // true

This works because we assign characterBStats to hold the same reference as characterAStats. But if we change something in characterBStats it will also be reflected in characterAStats.

const characterAStats = { strength: 16 }
const characterBStats = characterAStats

characterBStats.strength = 12

console.log(characterAStats.strength) // 12
console.log(characterBStats.strength) // 12

If we want to make a new object based on an existing one, we can spread its contents.

const characterAStats = { strength: 16 }
const characterBStats = { ...characterAStats }

characterBStats.strength = 12

console.log(characterAStats.strength) // 16
console.log(characterBStats.strength) // 12

But this will only work if the original object holds only primitive values. If it has nested objects or arrays, it will only pass the references to them, it won’t copy them.

const characterA = { stats: { strength: 16 } }
const characterB = { ...characterA }

characterB.stats.strength = 12

console.log(characterA.stats.strength) // 12
console.log(characterB.stats.strength) // 12

Coercion

When we’re working with data we often want to transform it from one type to another. Coercion is a term used for the not-obvious type casting that happens as a side effect of different logical operations.

JavaScript is very forgiving when you’re trying to work with variables of different types and it will try to align them so it can run your code.

42 + '' // "42"

Whenever you’re using the + or - operators, the values must be of the same type. They’re obviously not, so JS will cast the number to a string and concatenate them.

42 + '0' // "420"

But if we use the - operator, the string will be cast to a number instead. This is because strings don’t have a subtraction mechanism the way numbers do.

'42' - 7 // 35

Logical Operator Return Values

When you’re using a logical expression, the return value won’t be a boolean but the value of one of the two operands that were used.

const number = 42
const string = 'Drizzt'
const empty = null

a || b // 42
a && b // "Drizzt"
a || c // 42
a && c // null
b || c // "Drizzt"
b && c // null

When you use the || operator, if the first value casts to true you will get that value returned. Otherwise, you will always get the second one. In the case of && you will always get the second value if they are both coerced to true. If the first one casts to false then you will get it’s value returned.

This behavior is utterly confusing, but comes in handy when we want to do shorthand conditional assignments.

function greet(name) {
  console.log(`Hello, ${name || 'visitor'}!`)
}

greet() // Hello, visitor!

But in most modern JS codebases you will see default parameters assigned in the function signature instead.

function greet(name = 'visitor') {
  console.log(`Hello, ${name}!`)
}

greet() // Hello, visitor!

Equality

There are two equality operators in JavaScript - == and ===. The difference between them is how they deal with a difference in types between the compared values.

42 == '42' // true
42 === '42' // false

The == operator will compare the values and it will try to coerce them to the same type if they differ. The === operator on the other hand will compare them without coercion.

You should default to using the === operator to avoid subtle bugs.

Closures

The simplest possible explanation for a closure is an exported nested function.

We know that all variables and functions are only visible in a specific scope. But exported functions can remember their parent scope even when they’re used outside of it.

function createCharacter() {
  const stats = {
    strength: 16,
    dexterity: 14,
  }

  return {
    increaseLevel: function () {
      stats.strength += 2
      stats.dexterity += 2
    },
    getStats: function () {
      return stats
    },
  }
}

const character = createCharacter()
character.increaseLevel()
const stats = character.getStats()

console.log(stats.strength) // 18
console.log(stats.dexterity) // 16

The exposed increaseLevel and getStats functions will still have access to the stats variable even though they’re executed in an entirely different scope.

Closures are powerful because they provide us with a method of encapsulation. They allow us to hide data and expose only the functionality we see fit.

function createMerchantOrder(items) {
  function calculateItemTotal(items) {
    return items.reduce((acc, curr) => {
      return acc + curr.price
    }, 0)
  }

  function addCityTaxToPrice(price) {
    return price + price * 0.2
  }

  return {
    calculateTotal: () => {
      return addCityTaxToPrice(calculateItemTotal(items))
    },
  }
}

const items = [
  { name: 'Long Sword', price: 15 },
  { name: 'Shield', price: 10 },
]

const order = createMerchantOrder(items)
console.log(order.total) // undefined
console.log(order.addTaxToPrice) // undefined
console.log(order.calculateTotal()) // 30

In this example we have multiple function definitions that handle the details about the total price calculation, but we expose a single simple one.

The ‘this’ Keyword

In object-oriented languages like Java, the this keyword refers to the current object of a method or a constructor. If you use this inside an object, it will always refer to that object. But JavaScript’s implementation of this is slightly different.

First off, you can use this in regular functions and objects, not only classes.

function createCharacter(name) {
  return {
    name,
    greet: function () {
      console.log(`${this.name} says hello!`)
    },
  }
}

const character = createCharacter('Drizzt')
character.greet() // Drizzt says hello!

The code above produces the result that you would expect, but the value of this doesn’t depend on the function that uses is.

function createCharacter(name) {
  return {
    name,
    greet: function () {
      console.log(`${this.name} says hello!`)
    },
  }
}

const { greet } = createCharacter('Drizzt')
greet() // " says hello !"

In this case we get an empty value for this.name because this depends on the call-site. It doesn’t refer to the function in which it’s used or it’s scope. It refers to the object on which a function is being executed.

If you run the example above in the browser and log this, you will see that it refers to the window object and it has no name property on it.

A simple trick to remember it is to look what object sits on the left side of the function that uses this.

The ‘new’ keyword

In object-oriented languages, the new keyword is used to make a new instance of a class. Even though JavaScript has classes nowadays, the behavior of new is slightly different.

In OO languages, the new keyword will result in a call to the constructor of a class. But in JS we don’t need a class to build a new object. We can use a simple function for that purpose.

Technically, any function can be called with the new keyword in front of it.

function Character(name) {
  this.name = name
}

const character = new Character('Drizzt')
console.log(character.name) // Drizzt

When you use new before a function, it will create a new object and all bindings to this inside the function will be made to that newly created object, then it will be returned if the function doesn’t return anything.

But if the function returns an object, the above is not valid.

Usage of ‘this’ and ‘new’

Nowadays you will have little reason to use this and new outside of working with classes so you won’t have to keep all these specifics in mind. And when it comes to object-oriented JavaScript, they behave just like you would expect them to.

Personally, I don’t use classes and prefer a functional approach that relies on closures instead of this and factory functions instead of new. It makes it easier to communicate intent with people who are not well-versed in the language.

Prototypes

There are two ways in which you can build an inheritance object hierarchy in JavaScript, through prototypes or classes (which are just syntactic sugar over prototypes).

Objects in JS have a prototype property which is a reference to another object.

Whenever you use a property on an object, if it’s not found on the object itself, the engine will go look for it in the prototype. If it’s not found there it will go to the prototype’s prototype and so on until it reaches the Object.prototype.

The prototype of an object can be set directly by specifying the __proto__ property.

const character = {
  attack: function () {
    console.log('Swing!')
  },
}

const fighter = {
  characterClass: 'Fighter',
  __proto__: character,
}

fighter.attack() // Swing!

But the __proto__ property will only set the prototype explicitly for this specific specific object. Ideally, we’d want to set up the inheritance chain in a constructor function so every created object can have the correct prototype.

function Character(name) {
  this.name = name
  this.attack = function () {
    console.log(`${this.name} swings!`)
  }
}

function Fighter(name) {
  this.name = name
}

// Create a reference for the prototype
Fighter.prototype = new Character()

const fighter = new Fighter('Regdar')
fighter.attack() // Regdar swings!

By setting the prototype object on the constructor function, we ensure that all objects created by calling it with new will be set up properly.

Classes

Nowadays, working directly with prototypes is discouraged because of the high complexity involved around creating larger inheritance hierarchies. A few years ago, classes were introduced in the language as a syntactic sugar over prototypes.

By using classes you allow any engineer who’s taken an OOP course to understand your application.

class Character {
  constructor(name) {
    this.name = name
  }

  attack() {
    console.log(`${this.name}: swings!`)
  }
}

class Fighter extends Character {
  constructor(name) {
    super(name)
  }
}

const fighter = new Fighter('Redgar')
fighter.attack() // Redgar swings!

Composition

Inheritance is a powerful mechanism to extend entities but not every problem can fit into its mental model. Forcing the design of a solution with inheritance can lead to more complex and hard to maintain implementations.

Composition is an alternative approach that lets us put larger, more complex objects together by combining multiple small ones.

function canAttack(name) {
  return {
    attack: () => console.log(`${name} swings!`),
  }
}

function createCharacter(name) {
  return {
    ...canAttack(name),
  }
}

const character = createDogEntity('Redgar')
character.attack() // Redgar swings!

This is the approach I personally prefer because the objects I create can implement only the functionality they truly need. With normal inheritance we have to design complex hierarchies to describe the objects we need to work with.

To solve these design problems, many languages have mechanisms that allow multiple inheritance - creating a class with more than one parent.

In JavaScript, the best way to achieve that is through composition.

Inheritance and Performance

It’s important to note that if performance is of critical importance then defining a function on the prototype may be the better option instead of defining it directly on an object.

Functions attached to the prototype are only created once, while if they’re inlined in the object, each one will get its own copy.

Promises

A promise is an object that allows us to take action when a future event happens. It can have three possible states - pending, succeeded and failed.

Upon creation, the promise takes a function which will be passed two arguments - a resolution callback and a rejection callback.

const attackRoll = 18
const enemyArmorClass = 16

const canHitTarget = new Promise((resolve, reject) => {
  attackRoll > enemyArmorClass ? resolve() : reject()
})

But most often you will be on the consuming side of the promise, specifying what actions you want to execute upon resolution.

canHitTarget
  .then(() => {
    // Handle success
  })
  .catch(() => {
    // Handle failure
  })

The then method is available on the Promise object and it will run with the value passed to the resolve function. The catch runs if the reject function was called.

const attackRoll = 18
const enemyArmorClass = 16

const canHitTarget = new Promise((resolve, reject) => {
  const damage = Math.random()
  attackRoll > enemyArmorClass ? resolve(damage) : reject()
})

canHitTarget
  .then((damage) => {
    // Do something with the total damage
  })
  .catch(() => {
    // We don't pass anything on failure
  })

If the function that runs inside of then returns another promise, multiple then calls can be chained.

const addBonus = (damage) => {
  return new Promise((resolve) => {
    resolve(damage + 2)
  })
}

canHitTarget.then(addBonus).then((totalDamage) => {
  // Do something with the total damage
})

Async/Await

If the promise syntax is not to your liking, the async/await keywords provide a syntactic layer over it that will make your code read as if it was synchronous.

async function getAttackDamage() {
  const attackDamage = await canHitTarget()
  const totalDamage = await addBonus(attackDamage)

  return totalDamage
}

You can only use the await keyword inside a function marked as async and it will always return a promise. Because the code executes in a an asynchronous manner even if it reads synchronously.

You can still use .catch to act on a thrown error, but to maintain style consistency it would be better to rely on try-catch when you’re working with await.

async function getAttackDamage() {
  try {
    const attackDamage = await canHitTarget()
    const totalDamage = await addBonus(attackDamage)

    return totalDamage
  } catch (err) {
    // Attack was not successful
    return 0
  }
}

Event Loop

JavaScript is a single-threaded language but because it has an execution flow based on an event loop we can still run asynchronous operations without blocking the program.

On a high level, the engine runs an endless loop that checks if there are tasks waiting for it in a queue. If there are, it executes them then continues to wait for more.

Any code that can’t run immediatelly is queued and the execution continues until it exhausts the call stack.

setTimeout(() => {
  console.log('Later')
}, 1000)

console.log('Now')

// Output:
// Now
// Later

Even though the setTimeout call is done before the log, it will happen after it because it has to run after a second.

But even if we put a 0 timeout, the code will still execute after the second log.

setTimeout(() => {
  console.log('Later')
}, 0)

console.log('Now')

// Output:
// Now
// Later

Despite the lack of a timeout, any call to setTimeout is sent to a queue. So the engine will continue its execution until it empties the call stack and it will then reach in the queue and continue with the timeout.

As long as we keep the call stack full, the engine won’t execute any of the queued tasks.

setTimeout(() => {
  console.log('Later')
}, 0)

for (let i = 0; i < 100000; i++) {
  console.log(i)
}

// Output:
// 0
// 1
// 2
// ...
// 100000
// Later

The loop keeps adding items to the call stack and the loop never gets a chance to reach the task queue.

When asynchronous calls are queued using the same method i.e. setTimeout, their execution order follows the order in which they were added.

setTimeout(() => {
  console.log('Later')
}, 0)

setTimeout(() => {
  console.log('Even Later')
}, 0)

// Output:
// Later
// Even Later

But if we queue a promise and a call to setTimeout, the promise will be the first to run.

setTimeout(function () {
  console.log('Later')
}, 0)

Promise.resolve().then(function () {
  console.log('Also Later')
})

// Output:
// Also Later
// Later

This is because promises and timeouts are put on two separate queues that have different priorities. The setTimeout call goes on the macrotask queue (also known as the task queue), while the promise goes on the microtask queue (also known as the jobs queue).

The event loop prioritises the execution of pending microtasks, so the fulfilled promise’s callback will be called first. Then the event loop will handle the next pending macrotask.

Immediatelly after every macrotask runs, the event loop will run all pending microtasks before continuing with the macro again.

Tao of Node

Learn how to build better Node.js applications. A collection of best practices about architecture, tooling, performance and testing.