Repository: TypeScript
Issue: Using `var` in limited contexts to avoid runtime TDZ checks
Labels: Meta-Issue

This 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](https://github.com/microsoft/TypeScript/pull/51387). 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?

```ts
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.

```ts
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.

@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](https://github.com/microsoft/TypeScript/pull/52656#issuecomment-1421249664). 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 (https://github.com/microsoft/TypeScript/pull/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:

* Parser: https://github.com/microsoft/TypeScript/pull/52832 (often 10-13% saved)
* Binder: https://github.com/microsoft/TypeScript/pull/52903 (often 1-3% saved)
* Checker: https://github.com/microsoft/TypeScript/pull/52835 (often 3-5% saved)
* Emitter/Printer/Writer: https://github.com/microsoft/TypeScript/pull/52906 (often 1.5-3% saved)

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](https://bugs.chromium.org/p/v8/issues/detail?id=13723). 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.