Skip to content

GTM registry's global Window augmentation is irreconcilable with @gtm-support/core β€” unsuppressable TS2430 under TypeScript 7Β #852

Description

@Togetic

πŸ› The bug

dist/runtime/registry/google-tag-manager.d.ts augments the global Window unconditionally:

export interface GoogleTagManagerApi {
  google_tag_manager: GoogleTagManagerInstance
  dataLayer: DataLayer & { push: DataLayerPush }   // ← required
}

declare global {
  interface Window extends GoogleTagManagerApi {}
}

@gtm-support/core (the engine behind @gtm-support/vue-gtm, a widely used Vue/Nuxt GTM integration) declares the same member as optional:

// @gtm-support/core@3.0.1 lib/index.d.ts
declare global {
  interface Window {
    dataLayer?: DataLayerObject[]
  }
}

Interface declarations merge, so Window.dataLayer becomes DataLayerObject[] | undefined, which then fails the extends GoogleTagManagerApi clause this module itself added:

error TS2430: Interface 'Window' incorrectly extends interface 'GoogleTagManagerApi'.
  Types of property 'dataLayer' are incompatible.
    Type 'DataLayerObject[] | undefined' is not assignable to type 'DataLayer & { push: DataLayerPush; }'.
      Type 'undefined' is not assignable to type 'DataLayer & { push: DataLayerPush; }'.
        Type 'undefined' is not assignable to type 'DataLayer'.

Three things make this hard to live with:

  1. It fires even if you never touch GTM via this module. We use useScriptGoogleAnalytics and have never called useScriptGoogleTagManager β€” the augmentation ships regardless, because the registry composable's auto-import pulls the .d.ts into the program.
  2. It is unsuppressable from the consumer side. The error is reported at our declare global { interface Window { … } } sites, one per augmentation block, not at either dependency. With skipLibCheck: true a dependency-vs-dependency conflict is silently ignored; it only materialises because we also augment Window.
  3. tsc doesn't warn you β€” it just picks a winner. tsc@5.9.3 exits 0 here, because it does not verify merged interfaces against their extends bases. But the type it resolves is the optional one, so GoogleTagManagerApi's contract quietly loses:
    window.dataLayer.push({ event: 'x' })
    //     ~~~~~~~~~ TS18048: 'window.dataLayer' is possibly 'undefined'
    TypeScript 7 / tsgo does perform that check, which is how we found this. So this will surface for many more people as TS 7 adoption grows β€” it is not a tsgo bug.

πŸ› οΈ Minimal reproduction

Three files, strict: true, skipLibCheck: true, moduleResolution: "Bundler", lib: ["ESNext","DOM"]:

// upstream.d.ts β€” mirrors this module's GTM registry
interface DataLayerObject { [key: string]: any }
type DataLayer = Array<DataLayerObject>
type DataLayerPush = (...args: any[]) => number
interface GoogleTagManagerInstance { dataLayer: { gtmDom: boolean, [k: string]: unknown }, [id: string]: any }
interface GoogleTagManagerApi {
  google_tag_manager: GoogleTagManagerInstance
  dataLayer: DataLayer & { push: DataLayerPush }
}
declare global { interface Window extends GoogleTagManagerApi {} }
export {}
// gtmsupport.d.ts β€” mirrors @gtm-support/core
interface GtmDataLayerObject extends Record<string, any> { event?: string }
declare global { interface Window { dataLayer?: GtmDataLayerObject[] } }
export {}
// ours.ts β€” any first-party Window augmentation
declare global { interface Window { GTest: string } }
export {}
  • tsgo / tsc@7.0.2 β†’ TS2430 at ours.ts
  • tsc@5.9.3 β†’ exits 0 (and types window.dataLayer as possibly-undefined)
  • Delete ours.ts β†’ tsgo also exits 0, because skipLibCheck hides the lib-vs-lib conflict

πŸ’‘ Suggested fix

Make dataLayer optional in GoogleTagManagerApi:

 export interface GoogleTagManagerApi {
   google_tag_manager: GoogleTagManagerInstance
-  dataLayer: DataLayer & { push: DataLayerPush }
+  dataLayer?: DataLayer & { push: DataLayerPush }
 }

This is arguably more accurate independently of the conflict: window.dataLayer is created by the GTM snippet at load time, so before the script loads it genuinely is undefined. It would also make the two declarations compatible, since optional-vs-optional merges cleanly.

Alternatives, if changing the public type is undesirable:

  • Keep GoogleTagManagerApi as-is but drop the declare global { interface Window extends GoogleTagManagerApi {} } block. The type is already exported and used as useScriptGoogleTagManager<T extends GoogleTagManagerApi>, so the global augmentation is what creates the collision without being needed for the composable's own typing.
  • Gate the augmentation behind the registry actually being configured, so projects that don't use GTM don't inherit it. (Related: TypeScript issue with 'third-party-capital' and google-analytics (despite not using them)Β #135, where registry types for unused scripts broke a consumer's typecheck.)

ℹ️ Additional context

  • @nuxt/scripts@1.3.2, @gtm-support/vue-gtm@3.2.0 β†’ @gtm-support/core@3.0.1, Nuxt 4.4.8, vue-tsc 3.x.
  • Present in 0.13.2 too, with a byte-identical declaration β€” this is long-standing, not a recent regression.
  • The required dataLayer was added in fix(googleTagManager): add missing dataLayer typeΒ #388 ("fix(googleTagManager): add missing dataLayer type"), so I assume it is deliberate; the coexistence case just doesn't seem to have come up.
  • Workaround we're using meanwhile: a types-only pnpm patch removing the global augmentation, since we don't call useScriptGoogleTagManager. Happy to open a PR for whichever direction you prefer.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions