skip to content

In TypeScript, a file `globals.d.ts` containing only `interface Window { analytics: Analytics }` successfully adds `window.analytics` across the project. After you add `import type { Analytics } from './analytics'` to the top of that same file, every use of `window.analytics` starts failing. What changed, and how do you fix it?

level: middleimportance: must knowfreq 70%

answer

  1. top-level import changes file kind
  2. script declarations are global
  3. module declarations are local
  4. declare global reaches back out
  5. export {} for the mirror-image error

basics

~20 s

The top-level import turned that declaration file from a global script into a module, so its interface no longer merges with the global Window. Wrap the declaration in declare global inside the module to reach the global scope again.

solid answer

~40 s

TypeScript decides file kind by one rule: a file with any top-level `import` or `export` is a module, and a file with neither is a script whose declarations are global. Before the import, `interface Window` merged with the `Window` interface from the DOM lib, which is why `window.analytics` worked everywhere. Adding `import type` made the file a module, so the interface became a local, unexported type that merges with nothing. The fix is to say explicitly that you are reaching outward: wrap it in `declare global { interface Window { analytics: Analytics } }`. `declare global` is only legal inside a module, so in a file that has *no* imports you would instead add `export {}` to make it a module first — the mirror image of this bug.

code

typescript · 11 lines
typescript
// globals.d.ts — the file is a module because of the import,
// so the augmentation must be wrapped in declare global.
import type { Analytics } from './analytics';

declare global {
  interface Window {
    analytics: Analytics;
  }
  // var, not let: this is what makes globalThis.buildId type-check
  var buildId: string;
}

go deeper

for a junior

Recall the rule that a file with a top-level import or export is a module, and that global type declarations belong in a file without either — or inside declare global.

for a middle

Explain the merge with the DOM's Window interface, why the added import broke it, and both directions of the fix: declare global inside a module, export {} to make a script into one.

for a senior

Diagnose it from symptoms alone — errors appearing at every call site after an unrelated edit — and check program membership when an augmentation is ignored. Push for optional typing when the global may genuinely be absent at runtime.

for a principal

Decide whether globals should be augmented at all. Global augmentation is program-wide and unscoped, so set where such declarations live, who reviews them, and when an explicitly imported accessor module is the better contract.

## One rule decides everything: script or module TypeScript classifies each file by whether it has a top-level `import` or `export`. With one, the file is a **module** and every declaration in it is scoped to that module. With none, the file is a **script**, and its declarations go into the **global scope**, visible to every file in the program without any import. That rule is why the original `globals.d.ts` worked. `interface Window { analytics: Analytics }` in a script merges with the ambient `Window` interface declared in the DOM library, so `window.analytics` type-checks everywhere. The moment a top-level `import type` appears, the file becomes a module and the very same line declares a brand-new local interface named `Window` that shadows nothing and merges with nothing — hence the errors at every call site, typically "Property 'analytics' does not exist on type 'Window'". This is a nasty bug precisely because the trigger is invisible: someone adds an import to reference a type, and a declaration file that was global silently stops being global. ## declare global: the explicit escape hatch Inside a module, you reach the global scope with a global augmentation block: ```ts import type { Analytics } from './analytics'; declare global { interface Window { analytics: Analytics; } } ``` The rules around it are worth memorising: - `declare global` is legal **only** inside a module (or an ambient module declaration). In a script it is redundant and rejected, with the message "Augmentations for the global scope can only be directly nested in external modules or ambient module declarations." Seeing that error means the opposite problem: your file has no top-level import or export, so add `export {}` to make it a module. - It must be nested directly at the top level of the file, not inside a namespace or function. - Everything inside it is added to the global scope, so a name collision is a program-wide collision. ## Interfaces merge; other declarations do not Global augmentation leans on declaration merging, which works for interfaces and namespaces. That is why the DOM's `Window` is an `interface`: it is designed to be extended. You cannot patch a global **type alias** the same way — there is no merging for aliases, and re-declaring one is a duplicate-identifier error. ## Typing globalThis needs var A very common variant is a global that is not on `Window` — an injected variable read through `globalThis`. Only `var` and `function` declarations become properties of the global object type: ```ts export {}; declare global { var featureFlags: Record<string, boolean>; // globalThis.featureFlags OK let sessionId: string; // a global binding, NOT on globalThis } ``` With `var`, both bare `featureFlags` and `globalThis.featureFlags` type-check. With `let` or `const` you get the bare name only — which mirrors the runtime, where `let` and `const` at global scope do not create properties on the global object. The same mechanism covers host-specific namespaces. In a project whose Node type declarations are installed, `declare global { namespace NodeJS { interface ProcessEnv { API_KEY: string } } }` merges into the existing `ProcessEnv` interface so `process.env.API_KEY` is typed. It only works because those declarations already declare a `NodeJS` namespace containing that interface — you are extending something real, not inventing it. ## Getting the file into the program A declaration file is never imported, so it participates only if `files` or `include` in tsconfig matches it. If the augmentation seems to be ignored entirely, check membership in the program before you suspect the syntax; an editor may load a different tsconfig than the build does, which produces the classic "works in my IDE, fails in CI" split. ## It is still only a promise None of this emits JavaScript. Declaring `window.analytics` does not make the analytics snippet load; if the script tag is missing, you get `undefined` at runtime with no compile-time complaint. If the global is genuinely optional at some point in the page lifecycle, model it that way — `analytics?: Analytics` — so the checker forces callers to handle absence instead of pretending it is always there.

  • You add `declare global { ... }` to a file that has no imports and get "Augmentations for the global scope can only be directly nested in external modules". What is wrong?
    The file is a script, not a module, so its declarations are already global and the augmentation block is meaningless there. Either drop `declare global` and declare directly, or add `export {}` to turn the file into a module so the block becomes legal. It is the exact inverse of the bug where an added import silently de-globalises a declaration file.
  • Inside `declare global`, why does `var appConfig: Config` type `globalThis.appConfig` while `let appConfig: Config` does not?
    Only `var` and `function` declarations at global scope become properties of the global object, and TypeScript's model follows that runtime rule. `let` and `const` create a global binding you can reference bare, but they are not properties of `globalThis`, so `globalThis.appConfig` stays an error. Use `var` whenever the global is read through `globalThis` or `window`.
  • Why can you extend the DOM's Window this way but not a global type alias?
    Global augmentation relies on declaration merging, which TypeScript supports for interfaces and namespaces only. `Window` is an interface precisely so hosts and applications can extend it. A type alias has no merging rule, so a second declaration of the same alias name is a duplicate-identifier error; to extend one you must create a new intersection type instead.
  • Does declaring window.analytics guarantee the property exists when the code runs?
    No. `declare` is an unverified promise to the checker and emits nothing, so if the analytics script never loads you get `undefined` at runtime with a clean compile. If the global can be genuinely absent, type it as optional — `analytics?: Analytics` — so the checker makes every call site handle the missing case rather than trusting the declaration.

saying these in an interview costs you the question

  • Thinks a .d.ts file is always global regardless of imports
  • Adds declare global to a file that has no imports
  • Uses let instead of var for a globalThis property
  • Believes the declaration makes the global exist at runtime
  • Tries to extend a global type alias by re-declaring it

context