Narrowing stops at a function declaration

Seven errors appeared in one component the day type checking was switched on, under a guard that was correct. The difference between the lines that failed and the lines that passed was one keyword.


I turned on type checking in the build and one component produced seven errors at once. All the same error, all in the same block, all under a guard that was plainly correct:

'graph' is possibly 'null'.

The guard had been there for weeks. It looked like this, and still does:

const graph = document.getElementById('stack-graph'); // HTMLElement | null

if (graph) {
  // ...everything below is inside here
}

graph cannot be null inside that block. The compiler disagreed seven times, and re-reading the guard — the obvious first move — finds nothing wrong with it, because there is nothing wrong with it.

The seven lines, and the four that were fine

The errors were not scattered. Every one of them was inside a function declaration, and every use of graph in an arrow function was accepted:

if (graph) {
  function openPanel(id: string) {
    graph.querySelector(`[data-panel="${id}"]`);  // ✗ possibly 'null'
  }

  graph.addEventListener('click', (ev) => {
    graph.getBoundingClientRect();                // ✓ fine
  });
}

Same binding. Same block. Same guard, a few lines above both. One of them keeps the narrowed type and the other does not, and the only difference is the word function.

What is actually true

I assumed the rule was narrowing flows into closures but not into declarations hoisted out of the block. That sounded right, so I went to check it before writing it down, by compiling the same sixteen cases under every TypeScript release from 5.2 to 5.9.

It is not the rule. It is one row of a table with more structure than that:

Narrowed bindingConsuming function≤ 5.3≥ 5.4
module constfunction declaration
module constarrow, expression, callback, IIFE
module letfunction declaration
module letarrow, expression, callback
function-scope letfunction declaration
function-scope letarrow
parameterfunction declaration
parameterarrow

Three things fall out of it.

The function declaration is the only form that fails everywhere. Every other row depends on what is being narrowed, or on which compiler is reading it. A function declaration loses the narrowing for a const, for a let, for a parameter, at module scope, inside a function body, in all eight versions. It is the one invariant in the table.

A module-scope let is worse than I thought. It loses the narrowing in every closure form — arrows included — in the current release. My assumed rule would have got that backwards.

A parameter is not a let. Narrow a parameter with a guard and an arrow function keeps it, and always has, including before 5.4.

The feature that nearly covers this

TypeScript 5.4 shipped Preserved Narrowing in Closures Following Last Assignments. The release notes say:

When parameters and let variables are used in non-hoisted functions, the type-checker will look for a last assignment point.

“Non-hoisted” is the compiler team’s own word for the case that is excluded, and it is the only place I have found it written down. In the table above, 5.4 moved exactly one row — the function-scope let consumed by an arrow. The hoisted declaration is untouched by design.

It is also documented, in the sense that matters least: microsoft/TypeScript#42694, opened in February 2021 and closed. The handbook’s Narrowing chapter runs to about twenty-nine thousand characters and does not contain the word “hoist”.

What I did about it

Seven non-null assertions, one per error. graph! inside the three function declarations, plain graph everywhere else, and a comment at the top of the block saying why the file is inconsistent on purpose.

Rewriting the three declarations as const openPanel = () => {} would also have worked and I chose not to. They are hoisted deliberately — draw() and sync() call each other, and ordering them to satisfy the type checker would be letting the tool dictate the shape of the code. An assertion the compiler cannot verify is a cost; so is reordering a file around a rule nobody can see.

The part worth keeping

The guard was correct, the compiler was correct, and the disagreement was real. That combination is rarer than it sounds and it has a specific smell: when the error is about a fact you can see on screen, stop re-reading your own code and go find out what the tool actually believes.

Both times I have hit this on this site, the answer came from running the thing rather than reasoning about it — once by comparing two runtimes on the same string, and once here by compiling the same file under eight compilers. The table above is generated by that harness, not typed by hand, and it re-runs monthly: ts-narrowing-matrix.

One caveat, since it is the sort of thing this post is about. These seven errors arrived through astro check, which wraps TypeScript; the matrix compiles with tsc directly. Everything above is a statement about TypeScript’s type checker. I have not measured whether any other wrapper differs, and after the week I have had, I am not assuming it does not.