Javascript Basics: Scope
One of the more abstract concepts a developer needs to understand quite early into their development journey is the concept of scope. In a simplified way, scope means when is a variable available to be used. If a variable is not in scope, you cannot refer to it. Let's see how this works in practice in Javascript. We'll start with the base rules and then look at some of the exceptions and how they work.
The baseline
The best way to think about scope is this:
A variable enters the scope when it's created and exits the scope when the block ends. While it is in the scope, you can refer to it.
Creating a variable
A variable is created in Javascript with one of the three keywords: var
, const
or let
. So when you see a line let year = 2021
, the variable year
enters the scope. From this line onwards, you can refer to year
inside your code.
If you try to access a variable before it's been created (or initialized as the lingo goes), you'll get a ReferenceError: year is not defined
error.
There is a special case in Javascript for variables created with the keyword var
, I'll talk about that a bit later under the subheading Hoisting.
Block
Above I also mentioned the variable exiting the scope when "the block ends". A block starts with an open curly brace {
and ends with a closed curly brace }
. Examples of blocks you've probably already seen are functions, if clauses and for loops but you can also create a block with just the curly braces if you ever want to create a block with local scope.
The crucial part of understanding the concept of block is that any variable created inside that block will cease to exist when the block ends.
Functions
function createPerson(name, birthYear, hobbies) {
let currentAge = 2021 - birthYear;
let person = {
name: name,
age: currentAge,
birthYear: birthYear,
hobbies: hobbies
};
return person;
}
const molly = createPerson("Molly", 1992, ["hiking", "photography"]);
console.log(molly)
// prints out
// { name: "Molly", age: 29,
// birthYear: 1992,
// hobbies: ["hiking", "photography"] }
console.log(person) // ReferenceError
In the above example, we have two blocks: one for function createPerson
and the other one containing all the rest (including the function definition).
Any variable that's been defined either in the function definition (parameters: name
, birthYear
and hobbies
) or inside the function body (currentAge
and person
) are only accessible inside that function body. On the last line, we try to print out person
but we get ReferenceError
because person
is not defined in that scope.
This is crucial in understanding how the data flows in Javascript: to make something available inside a function, you need to pass it into it via arguments and to make something from inside a function available outside it, you need to pass it back via return
statement.
There's another way of creating a function in Javascript too but the same base rules apply to it:
const createPerson = (name, birthYear, hobbies) => {
let currentAge = 2021 - birthYear;
let person = {
name: name,
age: currentAge,
birthYear: birthYear,
hobbies: hobbies
};
return person;
}
Exceptions to the baseline
Understanding the baseline above will get you through most of the use cases and they are applicaple to most other programming languages as well. But it's valuable to understand the exceptions too for the moments when you run into them in code or need to use them yourself.
Hoisting
The first exception to the rule is called hoisting. It's a concept in Javascript describing how the compiler allocates memory before executing the code. But for practical purposes, what you need to know is how sometimes the declaration and initialization of a variable changes and how functions are hoisted.
Variables
Variables defined by var
keyword are hoisted while ones defined by either const
or let
are not. The latter two are more recent addition to the language and partly for this very reason.
console.log(message); // prints undefined
var message = "Message is gonna be hoisted";
In our above example, we don't get the ReferenceError
we'd get previously for trying to access a variable that's not in the scope. That's because here's what this code ends up being before it's ran:
var message;
console.log(message);
message = "Message is gonna be hoisted";
Javascript hoists the declaration of the variable to the top but not its definition so its value will be undefined
until line 3.
The reason hoisting of variables can be a problem is that variables defined with var
can be redeclared in the same scope:
var year = 2021;
var year = 2022;
This can become a problem if you have a long, complex code where you accidentally end up reusing a variable name.
For example's sake, let's say you have code like this.
var isAllowed = checkUserPermissions(user); // let's say it returns false
// 30 lines of code
if(isAllowed) { openDoors(); }
Now, later on, someone needs to add a piece of code in between:
var isAllowed = checkUserPermissions(user);
// 30 lines of code
var userList = [ { id: 1 }, { id: 2 }, { id: 3 }];
for (var user of userList) {
var isAllowed = user.id > 2;
user.isAllowed = isAllowed;
}
if(isAllowed) {
openDoors();
}
The developer writing the new code may not see that there already exists a variable called isAllowed
and thinks they are initializing a new variable for their local scope. However, because of hoisting, the isAllowed
inside the for
loop is modifying the exact same variable as the one on line 1.
With let
and const
this is not possible because there's no hoisting and because they cannot be redeclared.
Functions
Another thing that gets hoisted is function definitions that use function
keyword:
hello("World"); // prints Hello World
function hello(target) {
console.log(`Hello ${target}`);
}
This works because all function definitions (that use function
keyword) are hoisted so they are available anywhere. This is actually really nice because it allows you to define functions at the end of the file and write the main logic on top.
However, I mentioned earlier that you can define functions with the arrow syntax as well:
hello("World");
var hello = target => console.log(`Hello ${target}`);
Even when hello
is declared with var
keyword, the definition does not get hoisted, only the declaration so hello
is undefined in line 1 and we get TypeError: hello is not a function
if we try to run this.
Local and global scope
Another exception to the rule is global scope. In our Functions section I mentioned that the way data is passed into a function is through its arguments. This is not entirely the only way to do it, however it's highly suggested to avoid global variables because it's harder to keep track of them and see where they are changed.
let number = 42;
function addToNumber(value) {
return value + number;
}
console.log(addToNumber(20)); //prints 62
Any variable that's defined outside the functions is global and accessible anywhere after its initialization, including other blocks. In a small example like above, it's relatively easy to make sure addToNumber
returns what we want but what if someone adds another function call before it:
let number = 42;
makeMagicHappen();
function addToNumber(value) {
return value + number;
}
console.log(addToNumber(20));
Here, you don't know what makeMagicHappen
or any function it is calling is doing to number
. They can change it as they wish (it's global) so your function is now entirely dependent on every other piece of code that gets run before yours.