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
- Table of Contents
- Scope
- Variable Keyword Scope
- Hoisting
- Hoisting - Proper Explanation
- Variable Declaration
- Variables Software Design
- Values and References
- Coercion
- Logical Operator Return Values
- Equality
- Closures
- The ‘this’ Keyword
- The ‘new’ keyword
- Usage of ‘this’ and ‘new’
- Prototypes
- Classes
- Composition
- Inheritance and Performance
- Promises
- Async/Await
- Event Loop
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.