Optional Chaining an Undeclared Variable
undeclaredVar?.prop throws a ReferenceError, while const declaredVar = undefined; declaredVar?.prop evaluates to undefined. The difference happens before JavaScript reaches the optional part of the expression.
Optional chaining cannot protect an undeclared root identifier because JavaScript must resolve and read that identifier before it can test whether the resulting value is null or undefined.
Why undeclaredVar?.prop Still Throws
Optional chaining handles a value that is null or undefined. It does not handle a name that JavaScript cannot resolve.
Start with the two cases side by side:
try {
console.log(undeclaredVar?.prop);
} catch (error) {
console.log(error.name);
}
const declaredVar = undefined;
console.log(declaredVar?.prop);
ReferenceError
undefined
undeclaredVar is an undeclared identifier: JavaScript searches for a binding with that name and cannot find one. There is no variable to read and therefore no value for ?. to test.
declaredVar is different. Its binding exists, reading it produces the value undefined, and optional chaining recognizes that value as nullish. The property access stops and the complete expression returns undefined.
That distinction is the rule to keep.
An undeclared identifier is not an identifier whose value is undefined. One has no resolvable binding. The other has a binding and a value.
The operator has property, bracket-access, and optional-call forms:
const settings = undefined;
console.log(settings?.theme);
console.log(settings?.['theme']);
console.log(settings?.load?.());
undefined
undefined
undefined
All three forms first need a value for settings. Once they obtain undefined, they short-circuit. Optional chaining ?. covers the wider syntax; the root-identifier rule is the part that matters here.
Undeclared, Undefined, Null, or Missing?
Several expressions can look like “nothing was there,” but JavaScript reaches that result through different states. Two states throw a ReferenceError, while three safely produce undefined.
| State | Example | Result |
|---|---|---|
| Unresolvable identifier | missingRoot?.name | Throws ReferenceError |
| Temporal-dead-zone binding | pending?.name before let pending | Throws ReferenceError |
| Declared undefined value | let record; record?.name | Returns undefined |
| Null value | const record = null; record?.name | Returns undefined |
| Missing object property | const record = {}; record.owner?.name | Returns undefined |
Here is the complete matrix as executable code:
function report(label, read) {
try {
console.log(label, read());
} catch (error) {
console.log(label, error.name);
}
}
report('undeclared:', () => missingRoot?.name);
report('tdz:', () => {
return pending?.name;
let pending = null;
});
let undefinedRoot;
report('undefined:', () => undefinedRoot?.name);
const nullRoot = null;
report('null:', () => nullRoot?.name);
const project = {};
report('missing property:', () => project.owner?.name);
undeclared: ReferenceError
tdz: ReferenceError
undefined: undefined
null: undefined
missing property: undefined
The temporal dead zone, usually shortened to TDZ, is the period between entering a lexical scope and initializing a let, const, or class binding. The binding exists, but JavaScript does not allow it to be read yet. That read throws a ReferenceError.
A variable declared with var behaves differently because its binding is initialized to undefined when it is hoisted:
console.log(status?.label);
var status = { label: 'ready' };
undefined
Optional chaining receives undefined, so it returns undefined. This does not mean moving declarations below their uses is a sound loading strategy. It means var and lexical declarations have different initialization behavior, which The old var examines in detail.
The last matrix row involves two successful operations. JavaScript resolves project, reads the object, and then reads its absent owner property as undefined. The ?.name step sees that value and stops.
No error needs suppressing there.
What JavaScript Evaluates Before the Optional Chain
The expression root?.profile contains an optional property access, but JavaScript still has to evaluate root. The order explains the entire error.
For a root identifier, the relevant steps are:
- JavaScript resolves the name
rootto a binding. - It reads the value from that binding.
- It tests whether the value is
nullorundefined. - If the value is nullish, the expression returns
undefined. - Otherwise, it continues with the property access.
The specification describes the read in step 2 as GetValue. When the name resolution in step 1 produced an unresolvable reference, GetValue throws a ReferenceError.
The nullish test in step 3 never runs.
Picture the successful case first:
const account = undefined;
console.log(account?.profile);
undefined
JavaScript resolves account, reads undefined, and stops at the nullish test.
Now change only the root:
try {
console.log(missingAccount?.profile);
} catch (error) {
console.log(error.name);
}
ReferenceError
JavaScript cannot resolve missingAccount. Reading the root fails, so there is no root value to compare with null or undefined.
Optional chaining short-circuits operations inside the optional chain. It does not turn evaluation of the expression before the chain into an optional operation.
The same rule applies when a longer expression supplies the base:
function loadAccount() {
throw new ReferenceError('account source is unavailable');
}
try {
console.log(loadAccount()?.profile);
} catch (error) {
console.log(error.name);
}
ReferenceError
Calling loadAccount() is necessary to obtain the value that ?. would test. The call throws first, and optional chaining does not intercept that error. This is ordinary expression evaluation, the same foundation used by JavaScript code structure.
Choose the Fix That Matches the Cause
A ReferenceError identifies the failed read, but it does not decide what the program intended. The right fix depends on why the binding is unavailable.
Declare or import the binding
If the name is required program state, make the binding exist. Correct a misspelling, move the declaration into the needed scope, or add the missing import.
This code has no binding named preferences:
try {
console.log(preferences?.theme);
} catch (error) {
console.log(error.name);
}
ReferenceError
If the value is allowed to be absent, declare that fact:
const preferences = undefined;
console.log(preferences?.theme ?? 'system');
system
The ?? operator supplies the fallback because the optional chain produced undefined. Optional chaining controls access; nullish coalescing chooses a replacement value. Logical operators covers how nullish checks differ from truthiness checks.
For a module dependency, optional chaining is not a replacement for an import. If the program needs a local binding named formatter, that binding must be declared or imported.
Correct script loading or initialization order
A declaration can exist in another script and still be unavailable when the current script runs. In that case, adding more ?. operators treats the symptom while leaving the order wrong.
Suppose config.js initializes the binding:
const appConfig = { mode: 'preview' };
And app.js reads it:
console.log(appConfig?.mode);
Loading the reader first fails because appConfig is still undeclared:
<script src="app.js"></script>
<script src="config.js"></script>
Loading the initializer first produces the intended result:
<script src="config.js"></script>
<script src="app.js"></script>
preview
Classic scripts without async execute in document order. Deferred scripts also preserve their document order, while async scripts do not. Module scripts are deferred by default, but required dependencies should be expressed with imports rather than treated as optional globals.
Guard an intentionally optional identifier with typeof
typeof has a special rule for a genuinely undeclared identifier. It returns the string "undefined" instead of throwing:
if (typeof analytics !== 'undefined') {
analytics.track?.('page-view');
} else {
console.log('analytics unavailable');
}
analytics unavailable
This pattern fits a name that may genuinely be absent in the current environment. It is not the right response to a misspelled required variable.
There is a boundary. typeof does not safely cross a temporal dead zone:
try {
console.log(typeof session);
let session = {};
} catch (error) {
console.log(error.name);
}
ReferenceError
The session binding exists in the scope but has not been initialized. typeof protects an unresolvable identifier, not an early read of a lexical binding.
Inspect an optional global through globalThis
When a library is intentionally exposed as a global property, access it through globalThis:
const result = globalThis.analyticsSDK?.track?.('page-view');
console.log(result);
undefined
globalThis supplies the standard global-this value across JavaScript environments. Reading a missing property from that value produces undefined, which optional chaining can handle.
This remedy has a narrow purpose. globalThis.someLibrary?.start() fits an optional dependency that is designed to appear as a global property. It does not repair a missing module import, create a local binding, or fix code that ran before its required initialization.
Once root bindings, property access, and expression order feel predictable, JavaScript Fundamentals carries the same model through the rest of the language.
Related Optional-Chaining Errors
Optional chaining can appear near a ReferenceError, TypeError, or SyntaxError. The operator does not make those error classes interchangeable.
TypeError from an unguarded parent
Optional chaining begins at ?.; accesses evaluated before that point are unguarded. If it short-circuits, the rest of the same continuous chain is skipped:
const profile = undefined;
try {
console.log(profile.contact?.email);
} catch (error) {
console.log(error.name);
}
TypeError
JavaScript tries profile.contact before it reaches ?.email. Since profile is undefined, that first property access throws. Guard the parent instead:
const profile = undefined;
console.log(profile?.contact?.email);
undefined
TypeError from calling a non-function
An optional call handles an absent property, but not a present value that cannot be called:
const firstPlugin = {};
console.log(firstPlugin.run?.());
const secondPlugin = { run: 'later' };
try {
secondPlugin.run?.();
} catch (error) {
console.log(error.name);
}
undefined
TypeError
firstPlugin.run is absent, so ?.() returns undefined. secondPlugin.run exists and contains a string. The call is attempted and throws a TypeError.
TypeError after grouping breaks the chain
Short-circuiting continues through one continuous optional chain:
const account = null;
console.log(account?.profile?.name);
try {
console.log((account?.profile).name);
} catch (error) {
console.log(error.name);
}
undefined
TypeError
The first expression remains one chain. In the second, (account?.profile) finishes and produces undefined, then .name runs as a separate property access.
Reading versus assigning an undeclared name
Reading an undeclared identifier throws in both sloppy and strict code, before optional chaining can run:
console.log(missing?.name);
ReferenceError
Assignment differs. In a classic script without strict mode, assigning to an undeclared name may create a property on the global object:
createdByAssignment = 1;
console.log(globalThis.createdByAssignment);
1
In strict code, the same kind of assignment throws:
'use strict';
strictMissing = 1;
ReferenceError
Modules are always strict. Optional chaining concerns the read and does not convert it into assignment.
When the chain succeeds or short-circuits, ?? can provide a fallback:
const account = { profile: null };
console.log(account?.profile?.name ?? 'anonymous');
anonymous
The chain returns undefined because profile is null, then ?? returns "anonymous".
Debug the ReferenceError Step by Step
Start with the identifier named at the failing expression. Do not add a guard until the missing binding has an explanation.
- Check the spelling and capitalization of the root identifier.
- Find its declaration and confirm that the failing code is inside the declaration’s lexical scope.
- Check whether a
let,const, orclassbinding is read before its initialization. - Confirm that required module bindings are imported under the name the code uses.
- Inspect script execution order when one script creates a value consumed by another.
- Check whether the name exists only in a different environment, such as code that expects an optional host or library global.
- Decide whether absence is expected. Use
typeoforglobalThisonly for an intentionally optional name or global property. - Treat an absent required binding as a program defect and declare, import, initialize, or correctly scope it.
The final decision is small. If the binding must exist, fix how it is declared or loaded. If a global property may legitimately be absent, test that property. Optional chaining begins after JavaScript has a value to test.