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 binding | Consuming function | ≤ 5.3 | ≥ 5.4 |
|---|---|---|---|
module const | function declaration | ✗ | ✗ |
module const | arrow, expression, callback, IIFE | ✓ | ✓ |
module let | function declaration | ✗ | ✗ |
module let | arrow, expression, callback | ✗ | ✗ |
function-scope let | function declaration | ✗ | ✗ |
function-scope let | arrow | ✗ | ✓ |
| parameter | function declaration | ✗ | ✗ |
| parameter | arrow | ✓ | ✓ |
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
letvariables 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.