Optional Chaining an Undeclared Variable

Aug 24, 2026·15 min read

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.

StateExampleResult
Unresolvable identifiermissingRoot?.nameThrows ReferenceError
Temporal-dead-zone bindingpending?.name before let pendingThrows ReferenceError
Declared undefined valuelet record; record?.nameReturns undefined
Null valueconst record = null; record?.nameReturns undefined
Missing object propertyconst record = {}; record.owner?.nameReturns 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:

  1. JavaScript resolves the name root to a binding.
  2. It reads the value from that binding.
  3. It tests whether the value is null or undefined.
  4. If the value is nullish, the expression returns undefined.
  5. 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:

What does typeof encounter?typeof nameNo binding existsspecial typeof rulereturns“undefined”Binding exists inTDZreading is forbiddenReferenceError
typeof distinguishes an absent binding from a locked binding.
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.

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:

The first guard sets the boundaryGuard starts too lateprofile.contactthrows?.emailnotreachedGuard starts at the nullable valueprofile?.undefinedshort-circuitwholechainstops
Place the first optional access on the value that may be nullish.
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.

What value is stored in run?AbsentundefinedPresenta functionPresenta stringSkip the callreturnundefinedAttempt callfunction runsAttempt callnot callableTypeError
Optional call skips absence; it does not turn non-functions into functions.

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.

Does protection stay continuous?One continuous chainaccount?.profile?.namesafeParentheses finish the chain( account?.profile )produces undefinedchain boundary.name isseparateundefined → TypeError
Grouping ends the optional chain and leaves the next access unguarded.

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.

  1. Check the spelling and capitalization of the root identifier.
  2. Find its declaration and confirm that the failing code is inside the declaration’s lexical scope.
  3. Check whether a let, const, or class binding is read before its initialization.
  4. Confirm that required module bindings are imported under the name the code uses.
  5. Inspect script execution order when one script creates a value consumed by another.
  6. Check whether the name exists only in a different environment, such as code that expects an optional host or library global.
  7. Decide whether absence is expected. Use typeof or globalThis only for an intentionally optional name or global property.
  8. 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.

Frequently asked questions

Why does optional chaining throw ReferenceError for an undeclared variable?
JavaScript must resolve and read the root identifier before optional chaining can test its value. If no binding exists for that name, reading it throws a ReferenceError before the chain can return undefined.
Does optional chaining work with a variable whose value is undefined?
Yes. If the variable has been declared and its value is undefined, optional chaining returns undefined. This differs from an undeclared identifier, for which JavaScript cannot resolve a binding.
Can typeof safely check whether a variable exists?
typeof returns the string "undefined" for a genuinely undeclared identifier. It can still throw a ReferenceError for a let, const, or class binding accessed inside its temporal dead zone.
Should I use globalThis with optional chaining?
Use globalThis when an optional dependency is intentionally exposed as a global property. globalThis.someLibrary?.start() can tolerate a missing property, but it does not fix a missing import or an unavailable local binding.