skip to content

In JavaScript, what happens when you put a `function` declaration inside an `if` block, and how does the result differ between strict-mode code such as an ES module and a sloppy-mode script in a browser?

level: seniorimportance: should knowfreq 30%

answer

  1. ES2015 gave nested declarations a real rule
  2. block-scoped, like let
  3. non-strict code keeps a legacy compatibility layer
  4. filled in only when the block runs
  5. the if/else implementation-picking pattern

basics

~20 s

In strict-mode code, including ES modules, a function declared inside a block is scoped to that block and is not visible after it. In sloppy-mode scripts, legacy web semantics also create a function-scoped variable that is only filled in when the block actually runs.

solid answer

~40 s

Since ES2015 a function declaration inside a block is a block-scoped declaration, so in strict-mode code — which includes every ES module and class body — calling it after the block throws `ReferenceError: ... is not defined`. Sloppy-mode scripts keep a legacy web-compatibility behaviour on top of that: the name is *also* var-declared in the enclosing function or script scope, initialised to `undefined` at entry, and assigned the function only when control actually flows through the declaration. So the same code silently gives you `undefined` before the block, a working function after it, and a `ReferenceError` if you ever switch the file to strict mode or an ES module. Conditionally declaring a function in an if/else is the classic trap; declare a `let` binding before the block and assign function expressions inside it instead.

code

javascript · 22 lines
javascript
function sloppy() {
  console.log(typeof draw); // "undefined"
  if (true) {
    function draw() { return "drew"; }
  }
  console.log(typeof draw); // "function"
}

function strictVersion() {
  "use strict";
  if (true) {
    function draw() { return "drew"; }
  }
  try {
    draw();
  } catch (e) {
    console.log(e.constructor.name); // "ReferenceError"
  }
}

sloppy();
strictVersion();

go deeper

for a junior

Know that a function declared inside a pair of braces may not be usable after that block, and that the safe habit is to declare functions at the top level of a file or function body.

for a middle

Explain that ES2015 made a nested function declaration block-scoped like let, and that non-strict scripts keep an extra legacy binding in the enclosing scope that is only filled in once the block runs.

for a senior

Diagnose it from the symptom: the same code failing as a module or a bundle but working as a plain script. Show the rewrite using a declared binding plus function expressions, and explain why it is mode-independent.

for a principal

Own the migration risk — moving a codebase from classic scripts to modules or bundling turns this from a silent quirk into load-time errors. Decide the lint rule and the review stance that keeps declarations out of blocks entirely.

## The pre-2015 mess, and what fixed it A function declaration nested in a block was not defined by the ES5 grammar at all — it was a syntax extension every engine implemented differently. ES2015 finally specified it: **a function declaration inside a block is a block-scoped declaration**, like `let`. That is the rule to hold in your head. ```js "use strict"; { function helper() { return 1; } helper(); // 1 - fine inside the block } helper(); // ReferenceError: helper is not defined ``` The same applies inside `if`, `for`, `while`, `try` blocks and any other braces that form a block — but *not* to the top level of a function body or a script, where declarations remain function-scoped as always. ## The sloppy-mode legacy layer Making the new rule universal would have broken a great deal of existing web content, so the specification's web-compatibility annex adds an extra behaviour that applies **only to non-strict code** in hosts that implement it (browsers, and Node's non-strict scripts). Under it, the block-scoped declaration is still created, and in addition: - a `var`-style binding with the same name is created in the enclosing function or script scope, initialised to `undefined` when that scope is entered; - when control actually reaches the declaration inside the block, the current value of the block-scoped function is copied out to that outer binding. The observable result is a hybrid: the *name* behaves as if `var helper;` had been hoisted, while the *value* only appears once the block has run. ```js // sloppy-mode script, not a module console.log(typeof helper); // "undefined" if (true) { function helper() { return 1; } } console.log(typeof helper); // "function" ``` Run that as an ES module and the last line throws instead. Nothing about the file changed except its mode, which is what makes this trap so easy to hit during a migration from classic scripts to modules, or when adding `"use strict"` to an old file. ## The conditional-definition trap The pattern that motivates the question is choosing an implementation with if/else: ```js if (supportsFeature) { function render() { /* fast path */ } } else { function render() { /* fallback */ } } render(); ``` Under the legacy sloppy-mode rules, whichever branch executes copies its function out, so this happens to work — but it depends on run-time flow, not on where the code is written, and reviewers routinely misread it. In strict mode or a module it fails outright with a `ReferenceError`, because both `render` bindings died with their blocks. And in a `switch` statement — whose whole body is a single block — two branches declaring the same function name are in the *same* block, which makes the code a syntax error in strict mode rather than a run-time surprise. ## What to write instead Declare the binding at the level you want it and assign function *expressions* inside the branches. This has one meaning in every mode: ```js let render; if (supportsFeature) { render = () => { /* fast path */ }; } else { render = () => { /* fallback */ }; } render(); ``` Or, when both implementations are simple, hoist both to the top level and pick between them as values: ```js function fastRender() {} function fallbackRender() {} const render = supportsFeature ? fastRender : fallbackRender; ``` The second form is usually the better one: both functions are ordinary top-level declarations, so tooling, tests, and stack traces see real named functions, and the conditional part is reduced to choosing a value. ## Diagnosing it in the wild Three symptoms point here. A `ReferenceError: x is not defined` after a build change that turned scripts into modules. A `TypeError: x is not a function` at load time in a sloppy script, because something ran before the block that defines `x`. And behaviour that differs between a bundled build and a plain `<script>` load — bundlers emit module code, which is strict, so the legacy layer is not there to rescue you. In all three cases the fix is the same: stop relying on a declaration escaping its block. As a working rule, treat function declarations as legal only at the top level of a script, module, or function body. Anywhere inside braces, use an expression assigned to a binding you declared yourself.

  • Why does the same file behave differently once it is loaded as an ES module?
    Module code is always strict, and the legacy web-compatibility behaviour applies only to non-strict code. Without it the declaration is purely block-scoped, so the name simply does not exist after the block and the call throws `ReferenceError`. Bundled output is module code too, which is why a plain script can work while the built bundle fails.
  • Is a function declaration at the top level of a function body affected by this at all?
    No. Block scoping applies to declarations inside braces that form a block. At the top level of a function body, a script, or a module the declaration is scoped to that whole body as always, and is initialised with the finished function before the first statement runs. Only nesting inside a block changes the rules.
  • How would you rewrite a conditional implementation choice so it means the same thing in every mode?
    Declare both implementations as top-level function declarations and select one as a value: `const render = supportsFeature ? fastRender : fallbackRender;`. If the branches must be inline, declare `let render;` before the block and assign function expressions inside. Both forms are ordinary bindings, so nothing depends on legacy block-declaration behaviour.

saying these in an interview costs you the question

  • Says a function declared in a block is always visible after it
  • Assumes browser and module behaviour here are identical
  • Thinks if/else function declarations are a normal, safe pattern
  • Claims ES2015 made nested declarations illegal
  • Believes adding "use strict" cannot change which errors you get

context