Scope and declarations
Every declaration answers two separate questions. Where can this name be seen? That is its scope, it is fixed by the shape of your source, and nothing at run time can change it. When may the name be used? That is activation, and it is a question about time. const and let answer the first with "the innermost enclosing block" and the second with "from the line that declares it onward". Almost everything surprising about declarations is one of those two answers being different from what you assumed.
Create programs/scope-and-declarations.test.ts for this reference and keep it open. Start it with the assertion helpers used on this page:
import { assertEquals, assertThrows } from "@std/assert";
Follow the page as you add and revise the runnable examples below that import.
a block hides its declarations from the outside
A block is a pair of braces, and a const or let inside one belongs to it. Two declarations at the top of the file come along for the ride; they sit idle until there is no global scope to fall into, at the end of the page. Try to reach inner from outside its block and save:
// deno-lint-ignore no-var
var topLevelVar = 1;
const topLevelConst = 2;
Deno.test("a block hides its declarations from the outside", () => {
const outer = 1;
{
const inner = 2;
assertEquals(outer, 1);
assertEquals(inner, 2);
}
assertThrows(
() => {
return inner;
},
ReferenceError,
"inner is not defined",
);
});
Check programs/scope-and-declarations.test.ts
TS2304 [ERROR]: Cannot find name 'inner'.
return inner;
~~~~~
at file:///programs/scope-and-declarations.test.ts:17:16
error: Type checking failed.
info: The program failed type-checking, but it still might work correctly.
hint: Re-run with --no-check to skip type-checking.
Walk the directions.
- Inside the block,
outeris readable, because inner scopes read outward, andinneris readable on its own line. - Outside the block,
inneris not a subtle failure. The checker reportsCannot find name, before anything runs, because scope is visible in the source and the checker reads the same source you do.
Pin the rejection, and the run confirms what the reading claimed: outside the block, the name does not exist at all, and the throw's message says so in JavaScript's words.
Deno.test("a block hides its declarations from the outside", () => {
const outer = 1;
{
const inner = 2;
assertEquals(outer, 1);
assertEquals(inner, 2);
}
assertThrows(
() => {
// @ts-expect-error: outside the block, the name does not exist
return inner;
},
ReferenceError,
"inner is not defined",
);
});
Check programs/scope-and-declarations.test.ts
running 1 test from ./programs/scope-and-declarations.test.ts
a block hides its declarations from the outside ... ok (0ms)
ok | 1 passed | 0 failed (2ms)
Hold onto that error message, inner is not defined. A later step meets a different message for a different situation, and telling them apart is half of debugging declarations.
shadowing hides, it does not overwrite
You may reuse a name in a nested block. Predict both assertions about price:
Deno.test("shadowing hides, it does not overwrite", () => {
const price = 1;
{
const price = 2;
assertEquals(price, 2);
}
assertEquals(price, 1);
});
shadowing hides, it does not overwrite ... ok (0ms)
Inside the block, price is 2. After it, price is 1 again, untouched. Nothing was overwritten, because there was nothing shared to overwrite: these are two separate names that happen to be spelled the same, each belonging to its own block, and only one is reachable at a time. The inner one hides the outer one for the length of the block, which is called shadowing. It is useful when the inner name is a refinement of the outer one, and confusing when the two are unrelated ideas.
the same name twice does not even parse
Shadowing needs the nested block. The same name twice at the same level is not an error the program can meet at run time, because the file refuses to parse:
Deno.test("the same name twice does not even parse", () => {
assertThrows(
() => eval("let twice = 1; let twice = 2;"),
SyntaxError,
"has already been declared",
);
});
the same name twice does not even parse ... ok (0ms)
The eval is a device, and it needs explaining, since nothing else in these references uses it. A SyntaxError happens while the file is being parsed, before any test could run, so writing the duplicate directly into this file would stop the whole entry from loading. Handing the source to eval as a string delays the parse until the test runs, which lets assertThrows catch what is normally uncatchable. The claim it catches: two let declarations of twice in one scope are refused outright, with Identifier 'twice' has already been declared.
const by default, let when reassignment is the point
const says this name will never point at a different value. let says it might, and the reassignment is the reason to use it:
Deno.test("const by default, let when reassignment is the point", () => {
let counter = 0;
counter = counter + 1;
assertEquals(counter, 1);
const fixed = 10;
assertEquals(fixed, 10);
});
const by default, let when reassignment is the point ... ok (0ms)
This is about the binding and not the value: a const object can still have its contents changed, which the values-and-references page covers in const locks the name, not the contents.
Deno's linter enforces the preference without being asked. Write a let that is never reassigned, run deno lint, and:
error[prefer-const]: `unchanged` is never reassigned
--> programs/scope-and-declarations.test.ts:1:5
|
1 | let unchanged = 5;
| ^^^^^^^^^
= hint: Use `const` instead
docs: https://docs.deno.com/lint/rules/prefer-const
prefer-const is in the recommended set, and so is no-var, which appears two steps from now. Compare that with eqeqeq from the equality page, which you must turn on yourself: here the good advice is already the default.
the temporal dead zone
A const exists throughout its block, and using it before its declaration is an error rather than undefined. The direct version of the mistake never reaches run time. Type it and save:
Deno.test("the temporal dead zone", () => {
assertEquals(early, 1);
const early = 1;
});
Check programs/scope-and-declarations.test.ts
TS2448 [ERROR]: Block-scoped variable 'early' used before its declaration.
assertEquals(early, 1);
~~~~~
at file:///programs/scope-and-declarations.test.ts:52:18
'early' is declared here.
const early = 1;
~~~~~
at file:///programs/scope-and-declarations.test.ts:53:11
TS2454 [ERROR]: Variable 'early' is used before being assigned.
assertEquals(early, 1);
~~~~~
at file:///programs/scope-and-declarations.test.ts:52:18
Found 2 errors.
error: Type checking failed.
The checker sees the spatial version, because it reads the source top to bottom the same way you do. The version that reaches run time is the indirect one: a function reads the name, and the function gets called too early. If you learned var-era JavaScript, predict undefined and write that:
Deno.test("the temporal dead zone", () => {
function readsLater() {
return later;
}
assertEquals(readsLater(), undefined);
const later = 1;
assertEquals(readsLater(), 1);
});
Check programs/scope-and-declarations.test.ts
running 5 tests from ./programs/scope-and-declarations.test.ts
...
the temporal dead zone ... FAILED (1ms)
ERRORS
the temporal dead zone => ./programs/scope-and-declarations.test.ts:51:11
error: ReferenceError: Cannot access 'later' before initialization
return later;
^
at readsLater (file:///programs/scope-and-declarations.test.ts:53:7)
FAILURES
the temporal dead zone => ./programs/scope-and-declarations.test.ts:51:11
FAILED | 4 passed | 1 failed (3ms)
error: Test failed
Not undefined: a ReferenceError, thrown from inside the call. Read the message against the one from the first step.
inner is not definedmeant the name does not exist where you are standing.Cannot access 'later' before initializationmeans the name is real and not ready.laterexists throughout the block; the window between entering the scope and reaching its declaration is called the temporal dead zone, and the call landed inside it.
The committee had three options for this window: look the name up in the surrounding scope, hand back undefined, or throw. Looking outward had no precedent in the language. Returning undefined would have meant a constant with two different values in its lifetime. Throwing was the honest one. Record it with assertThrows, and keep the second call, which is what makes the zone temporal rather than spatial: the same call, after the declaration line, succeeds.
Deno.test("the temporal dead zone", () => {
function readsLater() {
return later;
}
assertThrows(
readsLater,
ReferenceError,
"Cannot access 'later' before initialization",
);
const later = 1;
assertEquals(readsLater(), 1);
});
the temporal dead zone ... ok (0ms)
a function is ready early; a class is not
Not every declaration waits for its line. Try a function and a class, both used above their declarations:
Deno.test("a function is ready early; a class is not", () => {
assertEquals(hoisted(), "callable above its own declaration");
function hoisted() {
return "callable above its own declaration";
}
const eager = new Later();
class Later {}
assertEquals(eager instanceof Later, true);
});
Check programs/scope-and-declarations.test.ts
TS2449 [ERROR]: Class 'Later' used before its declaration.
const eager = new Later();
~~~~~
at file:///programs/scope-and-declarations.test.ts:71:23
'Later' is declared here.
class Later {}
error: Type checking failed.
info: The program failed type-checking, but it still might work correctly.
hint: Re-run with --no-check to skip type-checking.
The function call above its declaration drew no complaint at all, and the class did. A function declaration is activated the moment its scope is entered, wherever it sits in that scope. A class is activated at its line, like a const, and using it early is the same temporal dead zone. The reason is not inconsistency for its own sake: the thing after extends is an expression, and an expression must be evaluated where it is written, so a class that extends the result of a function call cannot be hoisted above that call.
Move the early use into a closure and record the run-time half of the claim:
Deno.test("a function is ready early; a class is not", () => {
assertEquals(hoisted(), "callable above its own declaration");
function hoisted() {
return "callable above its own declaration";
}
assertThrows(
() => new Later(),
ReferenceError,
"Cannot access 'Later' before initialization",
);
class Later {}
assertEquals(new Later() instanceof Later, true);
});
a function is ready early; a class is not ... ok (0ms)
One caution about leaning on early activation: a function called above its declaration can reach other declarations that are not ready yet, and then a temporal dead zone error arrives from inside a call that looked fine, exactly as readsLater showed. Calling things after they are defined costs nothing and avoids the whole question.
var scopes to the function and starts as undefined
var predates block scope, and both of its answers differ from let's. Its scope is the innermost enclosing function. Its activation is the start of that function, holding undefined, with your assignment left where you wrote it:
Deno.test("var scopes to the function and starts as undefined", () => {
function varScope(): number {
// @ts-expect-error: the checker objects to reading v before assignment
const before: string = typeof v;
{
// deno-lint-ignore no-var no-inner-declarations
var v = 123;
}
assertEquals(before, "undefined");
return v;
}
assertEquals(varScope(), 123);
});
var scopes to the function and starts as undefined ... ok (0ms)
Two oddities in six lines of function.
typeof von the first line answers"undefined"rather than throwing. The name already exists when the function is entered, holdingundefined, so there is no dead zone to trip on. The pin records that TypeScript objects anyway: even where the language permits the early read, the checker discourages it.return vsucceeds after the block closed, becausevnever belonged to the block. It belongs to the function, so it escaped the braces it was written inside.
This is the behavior let was introduced to replace, and the two lint ignores on the declaration line measure how hard Deno pushes back: no-var and no-inner-declarations are both on by default, so writing this step took two signatures. You will not write var by accident. You only need to read it.
const is enforced twice
Reassigning a const is rejected by the checker. Suppress that, and the language throws anyway:
Deno.test("const is enforced twice", () => {
const total = 10;
assertThrows(
() => {
// deno-lint-ignore no-const-assign
// @ts-expect-error: the checker rejects assignment to a constant
total = 11;
},
TypeError,
"Assignment to constant variable.",
);
assertEquals(total, 10);
});
const is enforced twice ... ok (0ms)
Count the guards on this one line: the linter's no-const-assign, the checker's Cannot assign to 'total' because it is a constant, and finally JavaScript's own TypeError at run time. The contrast to draw is with readonly from the values-and-references page's readonly exists only at compile time, which is checked and then erased. const is part of JavaScript, so it holds in both moments: the reading and the running. The final assertion confirms total still holds 10 after the failed write.
there is no global scope to fall into
The two declarations from the top of the file finally earn their keep. In a browser script, a top-level var becomes a property of the global object, and older tutorials lean on that. Every file Deno runs is a module, and a module has its own scope:
Deno.test("there is no global scope to fall into", () => {
assertEquals(topLevelVar, 1);
assertEquals(topLevelConst, 2);
const globals = globalThis as Record<string, unknown>;
assertEquals(globals.topLevelVar, undefined);
assertEquals(globals.topLevelConst, undefined);
assertEquals(typeof globalThis, "object");
assertEquals(typeof globals.self, "object");
assertEquals(typeof globals.window, "undefined");
});
there is no global scope to fall into ... ok (0ms)
Read the pairs.
topLevelVarandtopLevelConstare reachable by name from inside the test, because module scope is the outermost scope this file's names live in.- Neither one landed on
globalThis, not even thevar. A top-levelvarin a module attaches to nothing global. - The global object itself still exists, reachable as
globalThis, andselfis an alias for it.window, the name browser tutorials reach for, does not exist in Deno at all.
So the global object, which the language considers a mistake it cannot remove, is something you can mostly ignore here. Use globalThis for feature detection and the rare polyfill, and reach for imports for everything else.
The table
Every declaration form, both questions:
| Declaration | Scope | Usable from | Duplicates |
|---|---|---|---|
const | block | its own line | no |
let | block | its own line | no |
class | block | its own line | no |
function | block | scope entry | yes |
var | function | scope entry, as undefined | yes |
import | module | scope entry | no |
Read the third column as the activation answer and the second as the scope answer. Everything in this entry is one row of that table behaving exactly as written.
In practice
- Declare a name in the smallest block that needs it, before its first use.
- Prefer
const, and let the linter identify aletthat never changes. - Shadow a name only when the inner value refines the outer one, not when the names represent different ideas.
- Read “Cannot access X before initialization” as “declared, not yet reached,” and “X is not defined” as “no such name.”
- Leave
varto code you are reading rather than writing. - In Deno, treat module scope as the outermost place your names live.