Skip to content

In "declare global" blocks, allow access of block-scoped variables in globalThis #56442

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
6 tasks done
DanKaplanSES opened this issue Nov 17, 2023 · 17 comments
Closed
6 tasks done
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug

Comments

@DanKaplanSES
Copy link

DanKaplanSES commented Nov 17, 2023

πŸ” Search Terms

globalThis, declare global block, let, const, var, property

βœ… Viability Checklist

⭐ Suggestion

This wouldn't be a breaking change in existing JavaScript code. When it comes to TypeScript code, I'm not as sure...

Before my request, here's an excerpt of #30477 for context:

TypeScript Version: 3.4.0-dev.20190316

Search Terms: 3.4 globalThis let const property

Code

// Compile with `tsc -t es2015`
const foo: number = 42;
const bar: null = globalThis.foo; // Type 'number' is not assignable to type 'null';

For reference, the following demonstrates how these bindings behave in browser and node, respectively:

<script>
const foo = 42;
alert(window.foo); // --> "undefined"
</script>
const foo = 42;
console.log(global.foo); // --> "undefined"

Expected behavior: TypeScript models types consistently with what's actually happening in VMs. Specifically, variables bound by let or const are not translated to properties on globalThis.

Actual behavior: TypeScript converts let/const bindings onto properties of globalThis, but these properties do not exist in ES2015+ output (when let and const are retained in the output).

The example and fix makes sense to me and I'm not requesting a change for that. But is there any chance #30510 / #30477 could work the way it used to within declare global blocks? In other words, I'm requesting for all of this code to compile:

export {}

declare global {
  const c: string;
  let l: string;
  var v: string;
}

globalThis.c = "s";  // errors: Property 'c' does not exist on type 'typeof globalThis'.
globalThis.l = "s";  // errors: Property 'l' does not exist on type 'typeof globalThis'.
globalThis.v = "s"; // compiles

πŸ“ƒ Motivating Example

The examples in the #30477 excerpt make sense to me because they're analogous to runtime behavior. e.g., I can intuit const foo: number = 42; shouldn't exist on globalThis.

But declare global { ... } doesn't exist at runtime in at all. It's a part of TypeScript that's erased at transpile. So if this is TypeScript-only, and it's named "declare global," I would assume my consts and lets apply to global scope.

Well it does.

Sometimes.

As a newb, it feels kind of arbitrary. For example, this code compiles like I would expect:

export {}

declare global {
  interface Window {
    foo: number;
  }

  function Y(): void;
}

globalThis.window.foo;
globalThis.Y();

And if I remove globalThis from my first example, it behaves the way I would expect:

export {}

declare global {
  const c: string;
  let l: string;
  var v: string;
}

c = "s";  // Cannot assign to 'c' because it is a constant.
l = "s";  // compiles
v = "s"; // compiles

So, it's conditionally global?

This stack overflow post shows some conversation around this topic..

πŸ’» Use Cases

  1. What do you want to use this for? I want it to be easier to learn how to declare global types.
  2. What shortcomings exist with current approaches? I think the current approach is nuanced and behaves in unexpected ways.
  3. What workarounds are you using in the meantime? I refer back to these links whenever global variable declarations don't work the way I expect
@andrewbranch andrewbranch added Bug A bug in TypeScript Help Wanted You can do this labels Nov 17, 2023
@andrewbranch andrewbranch added this to the Backlog milestone Nov 17, 2023
@andrewbranch
Copy link
Member

Maybe someone will discover a reason that I’m wrong, but I think this is just a bug.

@Josh-Cena
Copy link
Contributor

Josh-Cena commented Nov 18, 2023

dΓ©clare global means it's declared in a global scope (i.e. not inside a module, but inside a script, or indirect eval code). You should see declare global blocks as separate script declaration files (you can also create such declarations by not having any import/exports), which is why they are not allowed in ambient declarations because that would be redundant. However, let/const globals DO NOT become properties on globalThis; being able to access them via globalThis is a soundness error. The current behavior perfectly mirrors what happens at runtime. Global function and var declarations do become properties on globalThis at runtime.

@fatcerberus
Copy link

Shouldn’t the second case (where the variables are accessed without globalThis qualification) be an error too then, since they are declared in a script context but the access occurs in a module context?

@Josh-Cena
Copy link
Contributor

Josh-Cena commented Nov 19, 2023

Module scopes are child scopes of script scopes (which are a.k.a. the global scope).

@fatcerberus
Copy link

That doesn't really make sense to me - there's no analog for that kind of nesting (i.e. a module nested in a script scope such that it has access to the script's let/const vars) in native ECMAScript, is there?

@Josh-Cena
Copy link
Contributor

Josh-Cena commented Nov 19, 2023

I'm not sure what you mean by "native ECMAScript". ModuleEvaluation and ScriptEvaluation are both top-level operations invoked by the host only, so you need a host to test them out, let it be Node or HTML. You never have access to the script scope in Node, but you can easily do this in HTML:

<script>
const a = 1;
</script>
<script type="module">
console.log(a);
</script>

If it interests you: https://tc39.es/ecma262/multipage/ecmascript-language-scripts-and-modules.html#sec-runtime-semantics-scriptevaluation, https://tc39.es/ecma262/multipage/ecmascript-language-scripts-and-modules.html#sec-source-text-module-record-initialize-environment

BTW, the "nesting" does not happen in a lexical sense. It's merely that each time a script is evaluated, it directly uses the global scope, while when each module is evaluated, it creates a new scope that inherits from the global scope. There's no closures.

@fatcerberus
Copy link

Oh, I was reading it as, the const was scoped only to the file it occurred in while var and function goes on globalThis. I never knew it was possible to have a truly global variable that nonetheless wasn't a property of globalThis; that's... kinda confusing.

@Josh-Cena
Copy link
Contributor

Cross-script boundaries are totally weird: a declaration will be visible for any subsequent script, but it is only visible to preceding code within its current script.

<script>
console.log(a); // Cannot find variable
</script>
<script>
console.log(a); // undefined
var a = 2;
</script>
<script>
console.log(a); // 2
</script>

@DanKaplanSES
Copy link
Author

DanKaplanSES commented Nov 20, 2023

@Josh-Cena I'm in the process of writing a response to your first reply, but it may take a few days before I have the time to complete it.

@fatcerberus

<script>
const a = 1;
</script>
<script type="module">
console.log(a); // logs `1`
console.log(globalThis.a); // logs `undefined`
</script>

Oh, I was reading it as, the const was scoped only to the file it occurred in while var and function goes on globalThis.

Same! That's blowing my mind. I thought console.log(a); would log undefined, but it logs 1.

I also expected the following to "blow up," but it logs 5 and undefined, respectively:

<!DOCTYPE html>
<html>
  <head>
    <script>
      const a = 1;
    </script>
    <script type="module">
      const a = 5;
      console.log(a); // logs `5`
      console.log(globalThis.a); // logs `undefined`
    </script>
  </head>
  <body></body>
</html>

I assume this works because of what @Josh-Cena mentioned earlier:

each time a script is evaluated, it directly uses the global scope, while when each module is evaluated, it creates a new scope that inherits from the global scope.

That said, even though this runs (on Firefox, at least), Visual Studio code complains about the second const: Cannot redeclare block-scoped variable 'a'. Is that a VS Code bug?

This code, on the other hand, errors at runtime:

<!DOCTYPE html>
<html>
  <head>
    <script>
      const a = 1;
    </script>
    <script type="module">
      const a = 5;
      const a = 6; // error! Uncaught SyntaxError: redeclaration of const a
      console.log(a);
      console.log(globalThis.a);
    </script>
  </head>
  <body></body>
</html>

I never knew it was possible to have a truly global variable that nonetheless wasn't a property of globalThis; that's... kinda confusing.

I knew this was possible in TypeScript after finding this StackOverflow post, and it certainly surprised me. But until now, I didn't realize that applied to JavaScript too.

@fatcerberus
Copy link

Yeah, TS is just JS with compile-time types (plus a few value-add runtime features that got grandfathered in). So const has the same semantics in TS as in JS (or at least is supposed to).

@DanKaplanSES
Copy link
Author

DanKaplanSES commented Nov 20, 2023

@fatcerberus

Yeah, TS is just JS with compile-time types (plus a few value-add runtime features that got grandfathered in). So const has the same semantics in TS as in JS (or at least is supposed to).

Ah, I meant something slightly different. From that SO post:

On the other side, the following is possible, but the result is different:

export {}

declare global {
  let someLetString: string
  const someConstString: string
}

This only adds types for the following handles:

someLetString
someConstString

[As opposed to window.someLetString and/or globalThis.someLetString]

Although the ts compiler complains about window.someLetString and/or globalThis.someLetString not existing, I mistakenly assumed that during JS runtime, they would evaluate to the same value.

@andrewbranch andrewbranch removed this from the Backlog milestone Nov 20, 2023
@andrewbranch andrewbranch added Working as Intended The behavior described is the intended behavior; this is not a bug and removed Bug A bug in TypeScript Help Wanted You can do this labels Nov 20, 2023
@andrewbranch
Copy link
Member

TIL, thanks @Josh-Cena

@DanKaplanSES
Copy link
Author

DanKaplanSES commented Nov 21, 2023

@Josh-Cena

For declare global, I interpreted "global" as a reference to NodeJS's global variable, but you've taught me that's not the case. Apparently, "global" is a homonym even in the context of JavaScript, which I find unfortunate.

For what it's worth, I found this comment by @mhegazy that says:

"script"

A script is a file that is not a "module".
We should be using this instead of "global", as "global" is relative to the import, where as "script" is about the content of the file, not how it is used. this is also consistent with the ES6 [terminology].

This was written in 2016 so I'm not sure if it's still accurate.

If the decision were made today, I wonder if declare script would be a better name for declare global. That's neither here nor there, just food for thought.

I'll close this issue. Thanks again for taking the time to explain this!

@Josh-Cena
Copy link
Contributor

I think "script" is closer-to-the-metal while "global" is more abstract and fits user intuitions better. Most people won't run into the tiny nuancesβ€”and in no world would a "global const" become a property on globalThis anyway πŸ˜‰I don't have strong opinions about the status quo.

@fatcerberus
Copy link

in no world would a "global const" become a property on globalThis anyway

I was thinking this could have worked if the property was made non-writable (and non-configurable), but I guess yeah, the weird control-flow sensitive TDZ semantics would make implementing things that way unrealistic.

@DanKaplanSES
Copy link
Author

DanKaplanSES commented Nov 24, 2023

I think "script" is closer-to-the-metal while "global" is more abstract and fits user intuitions better.

I stand corrected. declare script would be worse.

Most people won't run into the tiny nuances

People tell me this but I wonder... Could it be that people run into these issues more than it seems, but they lack the knowledge to bring it up? I'm not asking rhetorically, but I'll offer my own experience:

window, global, and globalThis declarations worked the way I wanted occasionally, but not usually, and I could never get to the bottom of it. For the longest time, I thought it had something to do with the name of the declaration file and/or its path in my project and/or whether it's in a node_modules subdirectory or not. Until I found this stackoverflow answer, I didn't know what I didn't know.

After reading it, I thought I had a solid grasp on what was going on. But it turned out I was mistaken. It wasn't until this post that I learned it has more to do with JavaScript and "global" than TypeScript.

If I had the pressure of a deadline, I'd probably use (window as any), (global as any), or (globalThis as any) as a solution to get back to the main task at hand. When somebody solves the problem that way, their experience may never be shared with the TypeScript community.

This was kind of a rant, but it's not directed toward you. I'm trying to learn why my experience feels like the opposite of "Most people won't run into the tiny nuances." (It will probably be explained by "Your experience is not the norm," and I'm fine with that explanation, but I want to confirm with others before going with that assumption.)


@fatcerberus Sorry for reposting this comment. I made such a large edit it felt like a different reply.

@fatcerberus
Copy link

This might be the reason why ECMAScript didn't have a standard global (AKA globalThis) for as long as it did - because it doesn't behave intuitively. Until globalThis was standardized, we had window (browser) and global (Node), but they were always implementation-specific.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Working as Intended The behavior described is the intended behavior; this is not a bug
Projects
None yet
Development

No branches or pull requests

4 participants