Repository navigation
Preserve narrowing in closures for imports that resolve to const variables #64959
Description
Acknowledgement
- I acknowledge that issues using this template may be closed without further explanation at the maintainer's discretion.
Comment
Suggestion
A const keeps its narrowing inside callbacks. The same value imported from another module doesn't:
// @filename: a.ts
export const user: { name: string } | undefined =
Math.random() > 0.5 ? { name: "Ann" } : undefined;
// @filename: main.ts
import { user } from "./a";
if (user) {
user.name; // ok
setTimeout(() => user.name); // error TS18048: 'user' is possibly 'undefined'
}
const local = user;
if (local) {
setTimeout(() => local.name); // ok
}TypeScript already keeps narrowing in closures for const variables and unassigned parameters (#10357), and for closures created after a variable's last assignment (#56908). Imports can be narrowed too (#16658), but in a nested closure that narrowing is lost, even when the import resolves to a const. This proposal extends the existing const rule to that case. It doesn't ask TypeScript to reason about when callbacks run (#9998).
const keeps its narrowing in closures because nothing can reassign it between the check and the call. The same is true of these imports: in an ES module, an import is an immutable binding1 to the exporter's binding, a const binding is immutable too2, and writing to either throws at runtime3.
The prototype covers named and renamed imports, default imports of a const (export default user), and re-export chains, including export *. Unchanged:
export let: the exporting module can still reassign it;ns.userafterimport * as ns: a property access, not an identifier;- default-exported expressions (
export default (expr)): they don't resolve to aconst; - imported function and class declarations.
Motivating Example
Checking an imported constant now works inside callbacks, the same way it does for a local const:
import { logger } from "./logger"; // export const logger: Logger | undefined
if (logger) items.forEach(item => logger.info(item)); // no more "possibly 'undefined'"Use Cases
- Module-level values that may be absent: optional config, feature-flagged services (
export const analytics = enabled ? createAnalytics() : undefined), DOM lookups. They get checked once and then used in.map,setTimeoutor an event handler. - Removing workarounds: today people copy the import into a local
constor add!just to get past the checker. The copy is boilerplate, and!also hides a realundefinedlater. - Generated code. vue-tsc turns templates into TypeScript, where event handlers are arrow functions inside narrowed blocks, so imported constants started reporting errors there (
Property 'value' does not existfor imported object properties used in functions in template block vuejs/language-tools#6237). The proposed fix works around it in the generated code by unwrapping the reads inside closures (fix(language-core): unwrap imported bindings inside template closures vuejs/language-tools#6238). For imported constants, TypeScript could keep the narrowing itself.
Prototype
The prototype was written with the help of Claude Opus 5.5 and reviewed with GPT-6 Astra.
I tried this in the Go checker (main at 8d9b6da). JavaScript emit is unchanged. The change is one condition in checkIdentifier plus a helper (commit with the change and the test):
func (c *Checker) isConstantVariableOrAliasOfConstantVariable(symbol *ast.Symbol) bool {
// resolveAlias stops at a re-export merged with a type of the same name, so keep resolving
// until a symbol has a value meaning of its own
var seenSymbols collections.Set[*ast.Symbol]
for ast.IsNonLocalAlias(symbol, ast.SymbolFlagsValue) {
target := c.resolveAlias(symbol)
if target.Flags()&ast.SymbolFlagsAlias != 0 {
if target == symbol || seenSymbols.Has(target) {
return false
}
seenSymbols.Add(target)
}
symbol = target
}
return c.isConstantVariable(symbol)
}The change leaves the existing compiler baselines unchanged, and benchmarks on Vuetify and vuejs/core showed no statistically significant change in check time. The same check in the JavaScript checker gives identical diagnostics on eight open-source Vue projects (Nuxt, Vuetify, VueUse and others) before and after.
Compatibility: stronger narrowing can introduce diagnostics and change inferred types in previously accepted code, including types in emitted declarations. For example, given an exported const mode: "a" | "b":
import { mode } from "./a";
if (mode === "a") {
[1].map(() => mode === "b"); // new TS2367
const modes = [1].map(() => mode); // now "a"[], previously ("a" | "b")[]
modes.push("b"); // new TS2345
}These are the same diagnostics the example produces today with a local const mode instead of an import. The second one can break otherwise valid code; annotating the array (const modes: ("a" | "b")[]) keeps the old type.
CommonJS output is different: an import is read as a property (a_1.user), and other code could overwrite it. TypeScript already makes the same assumption in the exporting module: in a.ts, if (user) setTimeout(() => user.name) compiles to exports.user in both places and keeps its narrowing today. The change extends it to the importing side.
If this direction sounds right, I can open a PR with the change and the test.
Optional extension
Aliased conditions have the same gap for imports, even without a closure: const ok = typeof user === "object"; if (ok) user.name errors, unlike with a local const. Applying the same alias check in isConstantReference fixes it, and the existing baselines stay unchanged. I can include it in the same PR, or send it separately if you prefer.
Footnotes
-
ECMA-262, CreateImportBinding: an import "creates a new initialized immutable indirect binding" that resolves to the exporter's binding. ↩
-
ECMA-262, InitializeEnvironment for Source Text Module Records: a module-level
constis created with CreateImmutableBinding. ↩ -
ECMA-262, SetMutableBinding: writing an immutable binding throws a TypeError in strict mode code, and module code is always strict. ↩
Activity