GeezerVison is showing github.com — Using `var` in limited contexts to avoid runtime TDZ checks · Issue #52924 · microsoft/TypeScript
Using var in limited contexts to avoid runtime TDZ checks #52924DescriptionThis issue aims to explain and track some optimization work within the TypeScript compiler and language service.
As of TypeScript 5.0, the project's output target was switched from es5 to es2018 as part of a transition to ECMAScript modules. This meant that TypeScript could rely on the emit for native (and often more-succinct) syntax supported between ES2015 and ES2018. One might expect that this would unconditionally make things faster, but surprise we encountered was a slowdown from using let and const natively!
Why is this? It's because let and const both really provide two features:
- reasonable scoping rules -
let/const do not leak beyond the enclosing block scope.
- definite-declaration-checks - you cannot access a
let/const variable prior to their declarations being evaluated
What's that second point look like?
function f() {
x;
let x = 10;
return x;
}
Referring to x before let x = 10 has run is supposed to be an error. The idea is that x really shouldn't exist as far as anyone knows until its declaration ran. In a sense, this is just enforcing something similar in spirit to the first point around scoping rules - these let and const bindings can't be referenced until the runtime runs them; but it's enforced in a different way. The binding does sort of exist, but it's specifically an error to access it.
Why is this so subtle? The previous example is "obvious" - x is clearly used before it's declared. It happens earlier in the function than the declaration.
It's because we can capture and use these variables in functions. For example.
function f() {
let result = g();
let x = 10;
return result;
function g() {
return x;
}
}
Here, x ends up being accessed before it's declared when we called g() - even though g was declared after x.
This period while a binding exists but can't be accessed is often called the "temporal dead zone" (or TDZ for short). So JavaScript runtimes need to track whether they've actually hit this declaration point, and this does impose a run time cost. Often, implementations can perform optimizations and remove these checks, but it can be tough, and there are limitations.
Jake Bailey (@jakebailey) recently sent out a pull request (#52656) to experiment transforming only let and const to var by using a single Babel transformation. This does lose the TDZ checks, but we developed TypeScript like this for years without too many issues. We found some significant savings - close to 8% of time reduced on some of our benchmarks. But we've been resistant to adding another build step.
Given that the parser saw the biggest savings, and that many of the hard-to-eliminate TDZ checks for engines are for closure-captured variables, I sent a recent change (#52832) to swap a slew of shared state in our parser to simple use var. Given that these variables are always initialized before calling the "work-horse" functions that act on them, it seemed like a reasonable compromise with very little loss in "code cleanliness".
The savings here appear to be very close to those of those of the #52656! So for many functions in TypeScript, our strategy is to leverage var for top-level shared variables. So far, we've performed this optimization for the following components:
These have all seen improvements thanks to the elimination of TDZ checks! So we may continue to find other parts of the compiler that might benefit from these transformations, but at the moment we would like to limit our scope a bit.
When performing these transformations, we should leave a comment and link directly to this issue as an explainer to answer:
- Why is this code different?
- Why are we disabling our lint rules?
- Why is this more efficient?
Now long term, there are some possible improvements that the engines can apply. For example, V8 is currently tracking the issue here. In the future we may be able to swap back to the block-scoped declarations, but we'll probably want to delay that move until enough people are on these newer engines so they can easily benefit.
We also don't necessarily believe that all code should automatically jump to using var instead. We found a compromise based on well-understood tradeoffs for our codebase. We would recommend anyone else apply sound judgment and profiling/performance tests before performing broad refactorings like this. Reacted by Emmanuel Meric de Bellefon, ming, Ricardo Fernández Serrata, Gabriel Lundgren, Suraj, Zxilly, 染青42 and Sukka Reacted by SlurpTheo, Umar Bolatov, Salvad0r, Alexandre Costa, Pavel Savara, Gabriel Alves Cunha, Samuel Plumppu, noe132, Juhyeok Kang, Emmanuel Meric de Bellefon and 4 more Reacted by Adam Okhotskiy, Bradley Ayers, greentore, Ricardo Fernández Serrata, Samyak Sarnayak, Lucas Gauthier, donrel and Sukka Activityfatcerberus commented on Feb 22, 2023 inb4 node announces a switch to jsc which doesn't have this problem thus rendering the whole point moot (not that I think this will actually happen, but it would be amusing 😄) Reacted by Ricardo Fernández Serrata jakebailey commented on Feb 22, 2023 Truthfully, I'm not sure whether the reason JSC doesn't suffer from this problem is because it actually has optimized it better, or if their var is just as slow as their let/const.
(To be clear, this is not a dig at JSC; I honestly don't know how it works or how it compares to v8 outside the limited benchmarks related to this issue.) fatcerberus commented on Feb 22, 2023 On a more serious note, I'm personally leery of this change because the decision is based purely on implementation details of V8 that make let/const slower than var, which might not always be true. But I do understand the performance is important and to be fair it seems extremely unlikely that V8's var would ever become slower than let/const so probably a safe change. Still makes me uncomfortable. Reacted by Marcin Wojnarowski and Ricardo Fernández Serrata RyanCavanaugh commented on Feb 22, 2023
the decision is based purely on implementation details of V8
I'm trying to think of a situation where that's not the case and not coming up with much. There are a lot of micro-optimizations that depend on the runtime's behavior; it really can't be avoided. The ES spec doesn't specify which things are slow and which are fast. Reacted by SlurpTheo, Nico Jansen, Guilherme Simoes, exascape, Wessel Kronemeijer, ExE Boss, Suraj, 染青42 and Parsa fatcerberus commented on Feb 22, 2023 I guess I just prefer to stick to more high-level optimizations in my own code, like replacing a bubblesort with a quicksort, that sort of thing. Although I guess let -> var kind of falls under that umbrella if you squint a little, since the specified behavior is indeed simpler (i.e. no TDZ tracking)1.
Footnotes
-
The main thing I try to avoid is doing an awkward thing today because it's faster, only to find out tomorrow that the implementation has changed and now it's slower, thus incurring a bunch of technical debt for no reason. ↩
Reacted by Ryan Cavanaugh, SlurpTheo, Gabriel Alves Cunha, kritma, Ricardo Fernández Serrata and Ashok Chand DanielRosenwasser commented on Feb 23, 2023 I think we do too, but when 8% of your compile time is the engine doing TDZ checks, I'm not sure I could argue for implementing in a manner that intentionally ignores the runtime internals. On larger codebases, half a second is spent on these checks in the parser. And there's a lot more people using TypeScript than building it! Reacted by Bruce Pascoe, SlurpTheo, Guilherme Simoes, Gabriel Alves Cunha, 染青42 and Parsa DanielRosenwasser commented on Feb 23, 2023 Also, even if this got optimized in V8 by Node 20 or some future version, we would still want to run well on previous versions of Node.js too. fatcerberus commented on Feb 23, 2023
On larger codebases, half a second is spent on these checks in the parser.
If this is the case (which I'm not arguing; I believe you!), I haven't found a way to deduce that from typescript-bot's perf results, which for the most part only seem to exhibit differences in milliseconds... jakebailey commented on Feb 23, 2023
On larger codebases, half a second is spent on these checks in the parser.
If this is the case (which I'm not arguing; I believe you!), I haven't found a way to deduce that from typescript-bot's perf results, which for the most part only seem to exhibit differences in milliseconds...
See #52832 (comment); Angular and state both get about 0.5s faster, just from the parser change. Reacted by Bruce Pascoe, SlurpTheo and Gabriel Alves Cunha guilhermesimoes commented on Mar 10, 2023 Downleveling to var also has a bundle size impact. var is shorter than const and if we have a mix of const and let declarations, I assume they could all be grouped with a single var declaration. Reacted by Ricardo Fernández Serrata Reacted by Thomas Mclennan oyusypenko commented on Apr 17, 2025 Super interesting! We've hit perf bottlenecks in tight loops with let/const too, but never thought TDZ checks were the root cause.
Appreciate the pragmatic take on using var where it makes sense — especially for internal/compiler-level code. Rudxain commented on Apr 17, 2025 I assume TSGo makes this issue obsolete RyanCavanaugh commented on Apr 18, 2025 We've also already adopted var where needed in the existing codebase Reacted by Holger Jeromin and Ricardo Fernández Serrata Reacted by Ricardo Fernández Serrata
MetadataMetadataDevelopmentNo branches or pull requestsIssue actions
|