Renan Roggia's photo

Renan Roggia

I consider myself a tech problem solver.

You Dont know JS - Scope & Closures

Table Of Contents

The notes

Chapter 1 - What's the Scope?

Compiled vs Interpreted

Compiled: Typically, the whole source code is transformed at once, and those resulting instructions are saved as output (usually in a file) that can later be executed.

Interpretation performs a similar task to compilation, in that it transforms your program into machine-understandable instructions. But the processing model is different. Unlike a program being compiled all at once, with interpretation the source code is transformed line by line; each line or statement is executed before immediately proceeding to processing the next line of the source code.

Compiling code

In classic compiler theory, a program is processed by a compiler in three basic stages:

  1. Tokenizing/Lexing: breaking up a string of characters into meaningful (to the language) chunks, called tokens. For instance, consider the program: var a = 2;. This program would likely be broken up into the following tokens: var, a, =, 2, and ;. Whitespace may or may not be persisted as a token, depending on whether it's meaningful or not.

    (The difference between tokenizing and lexing is subtle and academic, but it centers on whether or not these tokens are identified in a stateless or stateful way. Put simply, if the tokenizer were to invoke stateful parsing rules to figure out whether a should be considered a distinct token or just part of another token, that would be lexing.)

  2. Parsing: taking a stream (array) of tokens and turning it into a tree of nested elements, which collectively represent the grammatical structure of the program. This is called an Abstract Syntax Tree (AST).

    For example, the tree for var a = 2; might start with a top-level node called VariableDeclaration, with a child node called Identifier (whose value is a), and another child called AssignmentExpression which itself has a child called NumericLiteral (whose value is 2).

  3. Code Generation: taking an AST and turning it into executable code. This part varies greatly depending on the language, the platform it's targeting, and other factors.

    The JS engine takes the just described AST for var a = 2; and turns it into a set of machine instructions to actually create a variable called a (including reserving memory, etc.), and then store a value into a.

Required two phases

While the JS specification does not require "compilation" explicitly, it requires behavior that is essentially only practical with a compile-then-execute approach.

There are three program characteristics you can observe to prove this to yourself: syntax errors, early errors, and hoisting.

Compiler speaks

Other than declarations, all occurrences of variables/identifiers in a program serve in one of two "roles": either they're the target of an assignment or they're the source of a value.

However, assignment targets and sources don't always literally appear on the left or right of an =, so it's probably clearer to think in terms of target / source rather than left / right.)

Target

A function declaration is a special case of a target reference. You can think of it sort of like var getStudentName = function(studentID), but that's not exactly accurate. An identifier getStudentName is declared (at compile time), but the = function(studentID) part is also handled at compilation; the association between getStudentName and the function is automatically set up at the beginning of the scope rather than waiting for an = assignment statement to be executed.

Lexical scope

If you place a variable declaration inside a function, the compiler handles this declaration as it's parsing the function, and associates that declaration with the function's scope. If a variable is block-scope declared (let / const), then it's associated with the nearest enclosing { .. } block, rather than its enclosing function (as with var).

a variable must be resolved as coming from one of the scopes that are lexically available to it; otherwise the variable is said to be "undeclared" (which usually results in an error!). If the variable is not declared in the current scope, the next outer/enclosing scope will be consulted. This process of stepping out one level of scope nesting continues until either a matching variable declaration can be found, or the global scope is reached and there's nowhere else to go.

compilation creates a map of all the lexical scopes that lays out what the program will need while it executes. You can think of this plan as inserted code for use at runtime, which defines all the scopes (aka, "lexical environments") and registers all the identifiers (variables) for each scope.

Chapter 2 - Illustrating Lexical Scope

The term "lexical" refers to the first stage of compilation (lexing/parsing).

Marbles, and Buckets, and Bubbles... Oh My!

References (non-declarations) to variables/identifiers are allowed if there's a matching declaration either in the current scope, or any scope above/outside the current scope, but not with declarations from lower/nested scopes.

We can conceptualize the process of determining these non-declaration marble colors during runtime as a lookup.

Nested scope

Scopes can be lexically nested to any arbitrary depth as the program defines.

Each scope automatically has all its identifiers registered at the start of the scope being executed (variable hoisting)

Lookup failures

If the variable is a source, an unresolved identifier lookup is considered an undeclared (unknown, missing) variable, which always results in a ReferenceError being thrown. Also, if the variable is a target, and the code at that moment is running in strict-mode, the variable is considered undeclared and similarly throws a ReferenceError.

The error message for an undeclared variable condition, in most JS environments, will look like, "Reference Error: XYZ is not defined." The phrase "not defined" seems almost identical to the word "undefined," as far as the English language goes. But these two are very different in JS, and this error message unfortunately creates a persistent confusion.

"Not defined" really means "not declared"—or, rather, "undeclared," as in a variable that has no matching formal declaration in any lexically available scope. By contrast, "undefined" really means a variable was found (declared), but the variable otherwise has no other value in it at the moment, so it defaults to the undefined value.

Chapter 3 - The scope chain

The connections between scopes that are nested within other scopes is called the scope chain, which determines the path along which variables can be accessed. The chain is directed, meaning the lookup moves upward/outward only.

"Lookup" Is (Mostly) Conceptual

Consider a reference to a variable that isn't declared in any lexically available scopes in the current file—see Get Started, Chapter 1, which asserts that each file is its own separate program from the perspective of JS compilation. If no declaration is found, that's not necessarily an error. Another file (program) in the runtime may indeed declare that variable in the shared global scope.

So the ultimate determination of whether the variable was ever appropriately declared in some accessible bucket may need to be deferred to the runtime.

Shadowing

Where having different lexical scope buckets starts to matter more is when you have two or more variables, each in different scopes, with the same lexical names. A single scope cannot have two or more variables with the same name; such multiple references would be assumed as just one variable.

When you choose to shadow a variable from an outer scope, one direct impact is that from that scope inward/downward (through any nested scopes) it's now impossible for any marble to be colored as the shadowed variable.

It's lexically impossible to reference the global studentName anywhere inside of the printStudent(..) function (or from any nested scopes).

Global Unshadowing Trick

It is possible to access a global variable from a scope where that variable has been shadowed, but not through a typical lexical identifier reference.

Copying Is Not Accessing

No. Mutating the contents of the object value via a reference copy is not the same thing as lexically accessing the variable itself. We still can't reassign the BLUE(2) special parameter.

Illegal Shadowing

Not all combinations of declaration shadowing are allowed. let can shadow var, but var cannot shadow let

Summary: let (in an inner scope) can always shadow an outer scope's var. var (in an inner scope) can only shadow an outer scope's let if there is a function boundary in between.

Function Name Scope

Such a function declaration will create an identifier in the enclosing scope.

function askQuestion() { // .. }

The same is true for the variable askQuestion being created. But since it's a function expression—a function definition used as value instead of a standalone declaration—the function itself will not "hoist".

A function without a name identifier is referred to as an "anonymous function expression."

Anonymous function expressions clearly have no name identifier that affects either scope.

var askQuestion = function(){ // .. };

One major difference between function declarations and function expressions is what happens to the name identifier of the function.

We know askQuestion ends up in the outer scope.

ofTheTeacher is declared as an identifier inside the function itself

Not only is ofTheTeacher declared inside the function rather than outside, but it's also defined as read-only

var askQuestion = function ofTheTeacher() { console.log(ofTheTeacher); }; askQuestion(); // function ofTheTeacher()... console.log(ofTheTeacher); // ReferenceError: ofTheTeacher is not defined

Arrow Functions

Arrow functions are lexically anonymous, meaning they have no directly related identifier that references the function.

Other than being anonymous (and having no declarative form), => arrow functions have the same lexical scope rules as function functions do.

Backing out

When a function (declaration or expression) is defined, a new scope is created. The positioning of scopes nested inside one another creates a natural scope hierarchy throughout the program, called the scope chain. The scope chain controls variable access, directionally oriented upward and outward.

Each new scope offers a clean slate, a space to hold its own set of variables. When a variable name is repeated at different levels of the scope chain, shadowing occurs, which prevents access to the outer variable from that point inward.

Chapter 4 - Around the global scope

Why Global Scope?

So how exactly do all those separate files get stitched together in a single runtime context by the JS engine?

First, if you're directly using ES modules (not transpiling them into some other module-bundle format), these files are loaded individually by the JS environment. Each module then imports references to whichever other modules it needs to access. The separate module files cooperate with each other exclusively through these shared imports, without needing any shared outer scope.

Second, if you're using a bundler in your build process, all the files are typically concatenated together before delivery to the browser and JS engine, which then only processes one big file. Even with all the pieces of the application co-located in a single file, some mechanism is necessary for each piece to register a name to be referred to by other pieces, as well as some facility for that access to occur.

In some build setups, the entire contents of the file are wrapped in a single enclosing scope, such as a wrapper function, universal module

And finally, the third way: whether a bundler tool is used for an application, or whether the (non-ES module) files are simply loaded in the browser individually (via tags or other dynamic JS resource loading), if there is no single surrounding scope encompassing all these pieces, the global scope is the only way for them to cooperate with each other

Most developers agree that the global scope shouldn't just be a dumping ground for every variable in your application.

But it's also undeniable that the global scope is an important glue for practically every JS application.

Where Exactly is this Global Scope?

Different JS environments handle the scopes of your programs, especially the global scope, differently.

Browser "Window"

the global object (commonly, window in the browser)

Globals Shadowing Globals

A simple way to avoid this gotcha with global declarations: always use var for globals. Reserve let and const for block scopes

DOM Globals

One surprising behavior in the global scope you may encounter with browser-based JS applications: a DOM element with an id attribute automatically creates a global variable that references it.

What's in a (Window) name?

We used var for our declaration, which does not shadow the pre-defined name global property. That means, effectively, the var declaration is ignored, since there's already a global scope object property of that name. As we discussed earlier, had we used let name, we would have shadowed window.name with a separate global name variable.

Web workers

Web Workers are a web platform extension on top of browser-JS behavior, which allows a JS file to run in a completely separate thread (operating system wise) from the thread that's running the main JS program.

Since there is no DOM access, the window alias for the global scope doesn't exist.

In a Web Worker, the global object reference is typically made using self

Developer Tools Console/REPL

The take-away is that Developer Tools, while optimized to be convenient and useful for a variety of developer activities, are not suitable environments to determine or verify explicit and nuanced behaviors of an actual JS program context.

ES Modules (ESM)

One of the most obvious impacts of using ESM is how it changes the behavior of the observably top-level scope in a file.

Despite being declared at the top level of the (module) file, in the outermost obvious scope, studentName and hello are not global variables. Instead, they are module-wide, or if you prefer, "module-global."

It's just that global variables don't get created by declaring variables in the top-level scope of a module.

The module's top-level scope is descended from the global scope, almost as if the entire contents of the module were wrapped in a function. Thus, all variables that exist in the global scope (whether they're on the global object or not!) are available as lexical identifiers from inside the module's scope.

Node

The practical effect is that the top level of your Node programs is never actually the global scope, the way it is when loading a non-module file in the browser.

As noted earlier, Node defines a number of "globals" like require(), but they're not actually identifiers in the global scope (nor properties of the global object). They're injected in the scope of every module, essentially a bit like the parameters listed in the Module(..) function declaration.

The only way to do so is to add properties to another of Node's automatically provided "globals," which is ironically called global.

Global This

As of ES2020, JS has finally defined a standardized reference to the global scope object, called globalThis. So, subject to the recency of the JS engines your code runs in, you can use globalThis in place of any of those other approaches.

Chapter 5 - The (Not So) Secret Lifecycle of Variables

When Can I Use a Variable?

Recall Chapter 1 points out that all identifiers are registered to their respective scopes during compile time. Moreover, every identifier is created at the beginning of the scope it belongs to, every time that scope is entered.

The term most commonly used for a variable being visible from the beginning of its enclosing scope, even though its declaration may appear further down in the scope, is called hoisting.

The answer is a special characteristic of formal function declarations, called function hoisting. When a function declaration's name identifier is registered at the top of its scope, it's additionally auto-initialized to that function's reference.

One key detail is that both function hoisting and var-flavored variable hoisting attach their name identifiers to the nearest enclosing function scope (or, if none, the global scope), not a block scope.

Declarations with let and const still hoist. But these two declaration forms attach to their enclosing block rather than just an enclosing function as with var and function declarations.

Hoisting: Declaration vs. Expression

Function hoisting only applies to formal function declarations

In addition to being hoisted, variables declared with var are also automatically initialized to undefined at the beginning of their scope—again, the nearest enclosing function, or the global. Once initialized, they're available to be used (assigned to, retrieved from, etc.) throughout the whole scope.

A function declaration is hoisted and initialized to its function value (again, called function hoisting). A var variable is also hoisted, and then auto-initialized to undefined. Any subsequent function expression assignments to that variable don't happen until that assignment is processed during runtime execution.

Variable Hoisting

There's two necessary parts to the explanation:

  • the identifier is hoisted,
  • and it's automatically initialized to the value undefined from the top of the scope.

Hoisting: Yet Another Metaphor

Rather than hoisting being a concrete execution step the JS engine performs, it's more useful to think of hoisting as a visualization of various actions JS takes in setting up the program before execution.

The typical assertion of what hoisting means: lifting—like lifting a heavy weight upward—any identifiers all the way to the top of a scope

I assert that hoisting should be used to refer to the compile-time operation of generating runtime instructions for the automatic registration of a variable at the beginning of its scope, each time that scope is entered.

Re-declaration?

A repeated var declaration of the same identifier name in a scope is effectively a do-nothing operation.

It's not just that two declarations involving let will throw this error. If either declaration uses let, the other can be either let or var, and the error will still occur

In other words, the only way to "re-declare" a variable is to use var for all (two or more) of its declarations.

Constants?

So if const declarations cannot be re-assigned, and const declarations always require assignments, then we have a clear technical reason why const must disallow any "re-declarations": any const "re-declaration" would also necessarily be a const re-assignment, which can't be allowed!

Loops

All the rules of scope (including "re-declaration" of let-created variables) are applied per scope instance. In other words, each time a scope is entered during execution, everything resets.

Each loop iteration is its own new scope instance, and within each scope instance, value is only being declared once. So there's no attempted "re-declaration," and thus no error.

Is value being "re-declared" here, especially since we know var allows it? No. Because var is not treated as a block-scoping declaration (see Chapter 6), it attaches itself to the global scope

The straightforward answer is: const can't be used with the classic for-loop form because of the required re-assignment.

Uninitialized Variables (aka, TDZ)

With var declarations, the variable is "hoisted" to the top of its scope. But it's also automatically initialized to the undefined value, so that the variable can be used throughout the entire scope.

However, let and const declarations are not quite the same in this respect.

the only way to do so is with an assignment attached to a declaration statement. An assignment by itself is insufficient!

The term coined by TC39 to refer to this period of time from the entering of a scope to where the auto-initialization of the variable occurs is: Temporal Dead Zone (TDZ).

The TDZ is the time window where a variable exists but is still uninitialized, and therefore cannot be accessed in any way. Only the execution of the instructions left by Compiler at the point of the original declaration can do that initialization. After that moment, the TDZ is done, and the variable is free to be used for the rest of the scope.

"temporal" in TDZ does indeed refer to time not position in code

The actual difference is that let/const declarations do not automatically initialize at the beginning of the scope, the way var does. The debate then is if the auto-initialization is part of hoisting, or not? I think auto-registration of a variable at the top of the scope (i.e., what I call "hoisting") and auto-initialization at the top of the scope (to undefined) are distinct operations and shouldn't be lumped together under the single term "hoisting."

So to summarize, TDZ errors occur because let/const declarations do hoist their declarations to the top of their scopes, but unlike var, they defer the auto-initialization of their variables until the moment in the code's sequencing where the original declaration appeared. This window of time (hint: temporal), whatever its length, is the TDZ.

always put your let and const declarations at the top of any scope. Shrink the TDZ window to zero (or near zero) length, and then it'll be moot.

Chapter 6 - Limiting Scope Exposure

Least Exposure

Principle of Least Privilege expresses a defensive posture to software architecture: components of the system should be designed to function with least privilege, least access, least exposure. If each piece is connected with minimum-necessary capabilities, the overall system is stronger from a security standpoint, because a compromise or failure of one piece has a minimized impact on the rest of the system.

Naming Collisions: if you use a common and useful variable/function name in two different parts of the program, but the identifier comes from one shared scope (like the global scope), then name collision occurs, and it's very likely that bugs will occur as one part uses the variable/function in a way the other part doesn't expect.

Unexpected Behavior: if you expose variables/functions whose usage is otherwise private to a piece of the program, it allows other developers to use them in ways you didn't intend, which can violate expected behavior and cause bugs.

Unintended Dependency: if you expose variables/functions unnecessarily, it invites other developers to use and depend on those otherwise private pieces.

Declare variables in as small and deeply nested of scopes as possible, rather than placing everything in the global (or even outer function) scope.

Hiding in Plain (Function) Scope

That means we can name every single occurrence of such a function expression the exact same name, and never have any collision. More appropriately, we can name each occurrence semantically based on whatever it is we're trying to hide, and not worry that whatever name we choose is going to collide with any other function expression scope in the program.

Invoking Function Expressions Immediately

we're defining a function expression that's then immediately invoked. This common pattern has a (very creative!) name: Immediately Invoked Function Expression (IIFE).

Unlike earlier with hideTheCache(), where the outer surrounding (..) were noted as being an optional stylistic choice, for a standalone IIFE they're required; they distinguish the function as an expression, not a statement. For consistency, however, always surround an IIFE function with ( .. ).

Function Boundaries

So, if the code you need to wrap a scope around has return, this, break, or continue in it, an IIFE is probably not the best approach. In that case, you might look to create the scope with a block instead of a function.

Scoping with Blocks

In general, any { .. } curly-brace pair which is a statement will act as a block, but not necessarily as a scope.

A block only becomes a scope if necessary, to contain its block-scoped declarations (i.e., let or const).

Not all { .. } curly-brace pairs create blocks (and thus are eligible to become scopes):

  • Object literals use { .. } curly-brace pairs to delimit their key-value lists, but such object values are not scopes.
  • class uses { .. } curly-braces around its body definition, but this is not a block or scope.
  • A function uses { .. }around its body, but this is not technically a block—it's a single statement for the function body. It is, however, a (function) scope.
  • The { .. } curly-brace pair on a switch statement (around the set of case clauses) does not define a block/scope.

In most languages that support block scoping, an explicit block scope is an extremely common pattern for creating a narrow slice of scope for one or a few variables. So following the POLE principle, we should embrace this pattern more widespread in JS as well; use (explicit) block scoping to narrow the exposure of identifiers to the minimum practical.

If you find yourself placing a let declaration in the middle of a scope, first think, "Oh, no! TDZ alert!" If this let declaration isn't needed in the first half of that block, you should use an inner explicit block scope to further narrow its exposure!

But the benefits of the POLE principle are best achieved when you adopt the mindset of minimizing scope exposure by default, as a habit. If you follow the principle consistently even in the small cases, it will serve you more as your programs grow.

var and let

That variable is used across the entire function (except the final return statement). Any variable that is needed across all (or even most) of a function should be declared so that such usage is obvious. var

var should be reserved for use in the top-level scope of a function.

Why not just use let in that same location? Because var is visually distinct from let and therefore signals clearly, "this variable is function-scoped."

Using let in the top-level scope, especially if not in the first few lines of a function, and when all the other declarations in blocks use let, does not visually draw attention to the difference with the function-scoped declaration.

As long as your programs are going to need both function-scoped and block-scoped variables, the most sensible and readable approach is to use both var and let together, each for their own best purpose.

Where To let?

"What is the most minimal scope exposure that's sufficient for this variable?" Once that is answered, you'll know if a variable belongs in a block scope or the function scope.

Declaration belongs in a block scope, use let. If it belongs in the function scope, use var (again, just my opinion).

Placing the var declaration for tmp inside the if statement signals to the reader of the code that tmp belongs to that block. Even though JS doesn't enforce that scoping, the semantic signal still has benefit for the reader of your code.

What's the Catch?

The err variable declared by the catch clause is block-scoped to that block. This catch clause block can hold other block-scoped declarations via let. But a var declaration inside this block still attaches to the outer function/global scope.

Function Declarations in Blocks (FiB)

So what about function declarations that appear directly inside blocks? As a feature, this is called "FiB."

The JS specification says that function declarations inside of blocks are block-scoped, so the answer should be (1).

As far as I'm concerned, the only practical answer to avoiding the vagaries of FiB is to simply avoid FiB entirely. In other words, never place a function declaration directly inside any block.

Always place function declarations anywhere in the top-level scope of a function (or in the global scope).

It's important to notice that here I'm placing a function expression, not a declaration, inside the if statement. That's perfectly fine and valid, for function expressions to appear inside blocks. Our discussion about FiB is about avoiding function declarations in blocks.

FiB is not worth it, and should be avoided.

Chapter 7 - Using closures

Closure builds on this approach: for variables we need to use over time, instead of placing them in larger outer scopes, we can encapsulate (more narrowly scope) them but still preserve access from inside functions, for broader use. Functions remember these referenced scoped variables via closure.

See the closure

Closure is a behavior of functions and only functions.

For closure to be observed, a function must be invoked, and specifically it must be invoked in a different branch of the scope chain from where it was originally defined.

While greetStudent(..) does receive a single argument as the parameter named greeting, it also makes reference to both students and studentID, identifiers which come from the enclosing scope of lookupStudent(..). Each of those references from the inner function to the variable in an outer scope is called a closure.

In academic terms, each instance of greetStudent(..) closes over the outer variables students and studentID.

Closure allows greetStudent(..) to continue to access those outer variables even after the outer scope is finished

Pointed closure

It's just important not to skip over the fact that even tiny arrow functions can get in on the closure party.

Adding Up Closures

closure is associated with an instance of a function, rather than its single lexical definition.

every time the outer adder(..) function runs, a new inner addTo(..) function instance is created, and for each new instance, a new closure.

Live Link, Not a Snapshot

Closure is actually a live link, preserving access to the full variable itself. We're not limited to merely reading a value;

the closed-over variable can be updated (re-assigned) as well!

But greeting() is closed over the variable studentName, not its value. The classic illustration of this mistake is defining functions inside a loop

Each of the three functions in the keeps array do have individual closures, but they're all closed over that same shared i variable.

Of course, a single variable can only ever hold one value at any given moment. So if you want to preserve multiple values, you need a different variable for each.

Each function is now closed over a separate (new) variable from each iteration, even though all of them are named j. And each j gets a copy of the value of i at that point in the loop iteration; that j never gets re-assigned.

What if I Can't see It?

In fact, global scope variables essentially cannot be (observably) closed over, because they're always accessible from everywhere. No function can ever be invoked in any part of the scope chain that is not a descendant of the global scope.

All function invocations can access global variables, regardless of whether closure is supported by the language or not. Global variables don't need to be closed over.

Variables that are merely present but never accessed don't result in closure:

Observable Definition

Closure is observed when a function uses variable(s) from outer scope(s) even while running in a scope where those variable(s) wouldn't be accessible.

The key parts of this definition are:

  • Must be a function involved
  • Must reference at least one variable from an outer scope
  • Must be invoked in a different branch of the scope chain from the variable(s)

The Closure Lifecycle and Garbage Collection (GC)

Since closure is inherently tied to a function instance, its closure over a variable lasts as long as there is still a reference to that function.

Closure can unexpectedly prevent the GC of a variable that you're otherwise done with, which leads to run-away memory usage over time.

Per Variable or Per Scope?

Conceptually, closure is per variable rather than per scope.

Closure must be per scope, implementation wise, and then an optional optimization trims down the scope to only what was closed over (a similar outcome as per variable closure).

In cases where a variable holds a large value (like an object or array) and that variable is present in a closure scope, if you don't need that value anymore and don't want that memory held, it's safer (memory usage) to manually discard the value rather than relying on closure optimization/GC.

An Alternative Perspective

Closure is the link-association that connects that function to the scope/variables outside of itself, no matter where that function goes.

Closure instead describes the magic of keeping alive a function instance, along with its whole scope environment and chain, for as long as there's at least one reference to that function instance floating around in any other part of the program.

Chapter 8 - The Module Pattern

Encapsulation and Least Exposure (POLE)

The goal of encapsulation is the bundling or co-location of information (data) and behavior (functions) that together serve a common purpose.

The recent trend in modern front-end programming to organize applications around Component architecture pushes encapsulation even further.

It's easier to build and maintain software when we know where things are, with clear and obvious boundaries and connection points.

What Is a Module?

A module is a collection of related data and functions (often referred to as methods in this context), characterized by a division between hidden private details and public accessible details, usually called the "public AP

Namespaces (Stateless Grouping)

Utils here is a useful collection of utilities, yet they're all state-independent functions. Gathering functionality together is generally good practice, but that doesn't make this a module.

Data Structures (Stateful Grouping)

Even if you bundle data and stateful functions together, if you're not limiting the visibility of any of it, then you're stopping short of the POLE aspect of encapsulation; it's not particularly helpful to label that a module.

Modules (Stateful Access Control)

classic module," which was originally referred to as the "revealing module" when it first emerged in the early 2000s.

The use of an IIFE implies that our program only ever needs a single central instance of the module, commonly referred to as a "singleton."

Classic Module Definition

So to clarify what makes something a classic module:

  • There must be an outer scope, typically from a module factory function running at least once.
  • The module's inner scope must have at least one piece of hidden information that represents state for the module.
  • The module must return on its public API a reference to at least one function that has closure over the hidden module state (so that this state is actually preserved).

Node CommonJS Modules

In some older legacy code, you may run across references to just a bare exports, but for code clarity you should always fully qualify that reference with the module. prefix.

CommonJS modules behave as singleton instances, similar to the IIFE module definition style presented before. No matter how many times you require(..) the same module, you just get additional references to the single shared module instance.

Modern ES Modules (ESM)

One notable difference is that ESM files are assumed to be strict-mode, without needing a "use strict" pragma at the top. There's no way to define an ESM as non-strict-mode.

Even though export appears before the function keyword here, this form is still a function declaration that also happens to be exported.

This is a so-called "default export," which has different semantics from other exports. In essence, a "default export" is a shorthand for consumers of the module when they import, giving them a terser syntax when they only need this single default API member.

A named import can also be renamed with the as keyword:

As is likely obvious, the * imports everything exported to the API, default and named, and stores it all under the single namespace identifier as specified.

Apendix A - Exploring Further

Implied Scopes

Parameter Scope

Here, studentID is a considered a "simple" parameter, so it does behave as a member of the BLUE(2) function scope. But if we change it to be a non-simple parameter, that's no longer technically the case. Parameter forms considered non-simple include parameters with default values, rest parameters (using ...), and destructured parameters.

My advice to avoid getting bitten by these weird nuances:

  • Never shadow parameters with local variables
  • Avoid using a default parameter function that closes over any of the parameters

Function Name Scope

The name identifier of a function expression is in its own implied scope, nested between the outer enclosing scope and the main inner function scope.

Anonymous vs. Named Functions

As you contemplate naming your functions, consider:

  • Name inference is incomplete
  • Lexical names allow self-reference
  • Names are useful descriptions
  • Arrow functions have no lexical names
  • IIFEs also need names

Explicit or Inferred Names?

Every function in your program has a purpose. If it doesn't have a purpose, take it out, because you're just wasting space. If it does have a purpose, there is a name for that purpose.

These are referred to as inferred names. Inferred names are fine, but they don't really address the full concern I'm discussing.

Missing Names?

The vast majority of all function expressions, especially anonymous ones, are used as callback arguments; none of these get a name. So relying on name inference is incomplete, at best.

Any assignment of a function expression that's not a simple assignment will also fail name inferencing

Who am I?

Leaving off the lexical name from your callback makes it harder to reliably self-reference the function. You could declare a variable in an enclosing scope that references the function, but this variable is controlled by that enclosing scope—it could be re-assigned, etc.—so it's not as reliable as the function having its own internal self-reference.

Names are Descriptors

There's just no reasonable argument to be made that omitting the name keepOnlyOdds from the first callback more effectively communicates to the reader the purpose of this callback. You saved 13 characters, but lost important readability information

If you can't figure out a good name, you likely don't understand the function and its purpose yet. The function is perhaps poorly designed, or it does too many things, and should be re-worked. Once you have a well-designed, single-purpose function, its proper name should become evident.

Arrow Functions

Don't use them as a general replacement for regular functions. They're more concise, yes, but that brevity comes at the cost of omitting key visual delimiters that help our brains quickly parse out what we're reading. And, to the point of this discussion, they're anonymous, which makes them worse for readability from that angle as well.

Briefly: arrow functions don't define a this identifier keyword at all. If you use a this inside an arrow function, it behaves exactly as any other variable reference, which is that the scope chain is consulted to find a function scope (non-arrow function) where it is defined, and to use that one.

So, in the rare cases you need lexical this, use an arrow function. It's the best tool for that job. But just be aware that in doing so, you're accepting the downsides of an anonymous function.

IIFE Variations

The !, +, ~, and several other unary operators (operators with one operand) can all be placed in front of function to turn it into an expression. Then the final () call is valid, which makes it an IIFE.

Hoisting: Functions and Variables

  • Executable code first, function declarations last
  • Semantic placement of variable declarations

Function Hoisting

The reason I prefer to take advantage of function hoisting is that it puts the executable code in any scope at the top, and any further declarations (functions) below.

I think function hoisting makes code more readable through a flowing, progressive reading order, from top to bottom.

Variable Hoisting

While that kind of inverted ordering was helpful for function hoisting, here I think it usually makes code harder to reason about.

const-antly Confused

The only time I ever use const is when I'm assigning an already-immutable value (like 42 or "Hello, friends!"), and when it's clearly a "constant" in the sense of being a named placeholder for a literal value, for semantic purposes. That's what const is best used for. That's pretty rare in my code, though.

Are Synchronous Callbacks Still Closures?

In this context, "calling back" makes a lot of sense. The JS engine is resuming our suspended program by calling back in at a specific location. OK, so a callback is asynchronous.

Synchronous Callback?

There's nothing to call back into per se, because the program hasn't paused or exited. We're passing a function (reference) from one part of the program to another part of the program, and then it's immediately invoked.

There's other established terms that might match what we're doing—passing in a function (reference) so that another part of the program can invoke it on our behalf. You might think of this as Dependency Injection (DI) or Inversion of Control (IoC).

Notably, Martin Fowler cites IoC as the difference between a framework and a library: with a library, you call its functions; with a framework, it calls your functions.

Synchronous Closure?