TypeScript globalThis Typing in 2026: Safely Adding Properties to the Global Scope Without any
This article was written with the assistance of AI, under human supervision and review.
Why globalThis Typing Still Matters in 2026
The global scope remains the path of least resistance for quick hacks. Developers drop variables onto globalThis to share state across modules, expose debug hooks to the browser console, or bridge third-party scripts that expect global names. TypeScript sees these assignments and defaults to any, erasing type safety at the exact boundary where typos and runtime crashes cluster.
The broken pattern looks innocent: globalThis.featureFlags = { darkMode: true } compiles without complaint, but every access to globalThis.featureFlags surrenders type checking. When another engineer misspells darkMode as darkmode three modules away, the compiler stays silent and the test suite passes until production traffic hits the undefined code path.
Declaration merging solves this. TypeScript permits extending the globalThis interface with new properties, restoring autocomplete and type errors for every global access. The corrected pattern uses an ambient declaration to teach the compiler about the new global without changing runtime behavior.
This pattern matters when a codebase has already committed to global state and rewriting it would delay shipping features. The technique also bridges legacy constraints: third-party analytics scripts, browser extensions that inject globals, or Node.js test runners that expose assertion libraries on global. Knowing when each variant (globalThis, window, global) breaks prevents silent failures in cross-environment builds.
Key Takeaways
- TypeScript allows extending the
globalThisinterface via declaration merging, restoring full type safety for global properties without runtime overhead. - The
declare globalblock works only in module files; script files can declare directly oninterface globalThisbut lose module isolation. - The
window,global, andglobalThisidentifiers reference different objects depending on the runtime environment, causing subtle breakage when the wrong one is typed. - Proper cleanup between test suites requires explicit
deletestatements, as TypeScript type declarations do not remove runtime properties. - Most use cases have better alternatives: environment variable objects, dependency injection, or module-scoped constants avoid global namespace pollution entirely.
The Problem: Adding Properties to globalThis Without any
A common scenario surfaces when integrating analytics libraries. The vendor provides a snippet that assigns a tracker instance to the global scope:
globalThis.analytics = {
track: (event, properties) => {
// implementation
},
identify: (userId, traits) => {
// implementation
}
};
TypeScript infers the type of globalThis.analytics as any because globalThis is a predefined global with a fixed type that does not include an analytics property. Every subsequent access to globalThis.analytics.track bypasses type checking. The engineer who writes globalThis.analytics.trak("click") will not receive an error until runtime.
TypeScript globalThis without proper typing
Attempting to add a type annotation at the assignment site fails:
globalThis.analytics = {
track: (event: string, properties: Record<string, unknown>) => {},
identify: (userId: string, traits: Record<string, unknown>) => {}
} as AnalyticsClient;
// Error: Property 'analytics' does not exist on type 'typeof globalThis'
The error reveals the core issue: TypeScript has a preexisting interface definition for globalThis that does not include an analytics member. Adding the property at runtime does not change the compile-time type.
The compiler blocks the assignment because it cannot verify that adding arbitrary properties to a global object is safe. The solution requires teaching TypeScript about the new property through declaration merging.
Declaration Merging: The Official TypeScript Pattern
Declaration merging extends an existing interface by declaring it again with additional members. TypeScript combines all declarations of the same interface into a single definition. For globalThis, this means adding a new interface declaration that specifies the custom property:
// types/global.d.ts
declare global {
interface globalThis {
analytics: {
track: (event: string, properties: Record<string, unknown>) => void;
identify: (userId: string, traits: Record<string, unknown>) => void;
};
}
}
export {};
The declare global block is necessary when the file is a module (contains an import or export statement). The empty export {} at the end forces TypeScript to treat the file as a module, which activates the declare global requirement. Without this, the declaration would apply to the module's local scope instead of the global scope.
After adding this declaration, the original assignment compiles without error:
globalThis.analytics = {
track: (event, properties) => {
console.log(event, properties);
},
identify: (userId, traits) => {
console.log(userId, traits);
}
};
Type checking now works across the entire codebase. Accessing globalThis.analytics.track provides autocomplete for the method signature, and misspelling the method name triggers a compile error:
// Autocomplete suggests: track, identify
globalThis.analytics.track("pageview", { path: "/home" });
// Error: Property 'trak' does not exist on type
globalThis.analytics.trak("click");
The declaration can reference custom types for cleaner definitions:
// types/global.d.ts
interface AnalyticsClient {
track: (event: string, properties: Record<string, unknown>) => void;
identify: (userId: string, traits: Record<string, unknown>) => void;
}
declare global {
interface globalThis {
analytics: AnalyticsClient;
}
}
export {};
This pattern works for any property type. Complex nested objects, function signatures, and generic constraints all merge correctly. The TypeScript compiler released version 3.4 with explicit support for typing globalThis, as documented in the TypeScript 3.4 release notes.
globalThis vs window vs global: When Each Pattern Breaks
The JavaScript language provides three identifiers for the global object: globalThis, window, and global. Each identifier works in specific environments, and typing the wrong one causes silent failures.
| Identifier | Browser | Node.js | Web Worker | When it breaks |
|---|---|---|---|---|
globalThis |
✓ | ✓ | ✓ | Never (ES2020+) |
window |
✓ | ✗ | ✗ | Node.js and Worker environments throw ReferenceError |
global |
✗ | ✓ | ✗ | Browser and Worker environments have no global
|
Typing window creates code that compiles for browser targets but crashes in Node.js:
// types/global.d.ts
declare global {
interface Window {
analytics: AnalyticsClient;
}
}
export {};
// runtime.ts
window.analytics = { /* ... */ };
// ReferenceError: window is not defined (in Node.js)
The same issue occurs when typing global for Node.js projects that later run in a browser:
// types/global.d.ts
declare global {
namespace NodeJS {
interface Global {
analytics: AnalyticsClient;
}
}
}
export {};
// runtime.ts
global.analytics = { /* ... */ };
// ReferenceError: global is not defined (in browser)
The globalThis identifier solves this. Introduced in ES2020, globalThis references the global object in all environments. Browsers alias it to window, Node.js aliases it to global, and Web Workers provide it directly. Code that uses globalThis runs unchanged across all targets.
TypeScript's type definitions for globalThis live in lib.es2020.d.ts. Projects targeting ES2020 or later automatically include these definitions. For older targets, developers must add "lib": ["ES2020"] to tsconfig.json or include a polyfill that backfills the globalThis identifier at runtime.
Module vs Script Context: Why declare global Sometimes Fails
TypeScript distinguishes between module files and script files based on the presence of import or export statements. This distinction changes how global declarations work.
A script file has no import or export statements. Its declarations apply to the global scope by default:
// types/global.d.ts (script file)
interface globalThis {
analytics: AnalyticsClient;
}
This works without the declare global wrapper. TypeScript merges the interface directly into the global globalThis type. However, the file lacks module isolation. Any types defined here pollute the global namespace and can conflict with types in other files.
A module file contains at least one import or export statement. Module files have their own scope, so declarations do not affect the global scope unless wrapped in declare global:
// types/global.d.ts (module file)
export {};
interface globalThis {
analytics: AnalyticsClient;
}
// This declaration applies to the module scope, not the global scope
The globalThis interface above creates a local type that shadows the global globalThis. Accessing globalThis.analytics in another file still returns any because the global type has not changed.
The correct pattern for a module file uses declare global:
// types/global.d.ts (module file)
declare global {
interface globalThis {
analytics: AnalyticsClient;
}
}
export {};
The export {} forces the file to be a module while exporting nothing. This preserves module isolation while allowing the declare global block to modify the global scope.
Projects that use ES modules (.mts extension or "type": "module" in package.json) default to module context. Adding a global type declaration to such a project without declare global produces no effect, and the compiler offers no warning about the mistake.
Real-World Patterns: Environment Variables, Feature Flags, and Runtime Config
Three scenarios drive most globalThis typing: environment variables exposed by bundlers, feature flags controlled by remote config, and runtime configuration injected by server-side rendering.
Environment Variables
Bundlers like Vite expose environment variables through import.meta.env. TypeScript does not know which variables the bundler will inject, so accessing import.meta.env.VITE_API_URL returns string | undefined. Declaring the variables restores specificity:
// env.d.ts
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_ENABLE_ANALYTICS: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
This pattern extends ImportMeta instead of globalThis, but the principle is identical. The declaration merges into the existing interface, and all accesses to import.meta.env gain type safety.
Some projects assign environment variables directly to globalThis for access in non-module code:
// runtime-env.ts
declare global {
interface globalThis {
ENV: {
API_URL: string;
ENABLE_ANALYTICS: boolean;
};
}
}
globalThis.ENV = {
API_URL: import.meta.env.VITE_API_URL,
ENABLE_ANALYTICS: import.meta.env.VITE_ENABLE_ANALYTICS === "true"
};
export {};
This approach centralizes the boolean parsing logic and provides a single typed object for environment configuration.
Feature Flags
Remote configuration services deliver feature flags as JSON. The consuming application receives an object with boolean properties:
// feature-flags.d.ts
declare global {
interface globalThis {
featureFlags: {
enableDarkMode: boolean;
enableBetaFeatures: boolean;
maxUploadSizeMB: number;
};
}
}
export {};
// init.ts
fetch("/api/feature-flags")
.then(res => res.json())
.then(flags => {
globalThis.featureFlags = flags;
});
Components that check flags receive autocomplete and type errors:
if (globalThis.featureFlags.enableDarkMode) {
document.body.classList.add("dark");
}
// Error: Property 'enableDrakMode' does not exist
if (globalThis.featureFlags.enableDrakMode) {
// typo caught at compile time
}
The risk here is runtime shape mismatch. If the API response changes and removes enableDarkMode, the application will read undefined at runtime despite the TypeScript type claiming a boolean. Adding runtime validation with a library like Zod or writing a manual assertion function mitigates this.
TypeScript globalThis feature flags pattern
Server-Side Rendering Config
Frameworks like Next.js inject configuration into the initial HTML payload. The server renders a `tag that assigns towindow`:
`html
window.INITIAL_STATE = {
user: { id: "123", name: "Alice" },
cart: []
};
`
The client-side code reads this state before making API calls:
`typescript
// initial-state.d.ts
declare global {
interface Window {
INITIAL_STATE: {
user: { id: string; name: string };
cart: Array<{ productId: string; quantity: number }>;
};
}
}
export {};
`
`typescript
// hydrate.ts
const initialState = window.__INITIAL_STATE__;
renderApp(initialState);
`
This pattern works only in browser environments. Server-side code that references window.__INITIAL_STATE__ will crash. Conditional checks guard the access:
`typescript
const state = typeof window !== "undefined"
? window.__INITIAL_STATE__
: getDefaultState();
`
The type system does not enforce this check. Developers must remember to add it, or the build will succeed and production will fail when server-side rendering tries to reference window.
Testing and Cleanup: Avoiding Pollution Between Test Suites
Global state persists across test files when a test runner executes all files in a single process. A test that assigns globalThis.featureFlags = { enableBeta: true } leaves that property set for subsequent tests. The next test suite that checks if (globalThis.featureFlags.enableBeta) will read the previous value instead of the expected undefined, causing false passes or failures.
The TypeScript declaration does not remove runtime properties. Deleting the type does not reset the global:
`typescript
// This does not work:
declare global {
interface globalThis {
featureFlags: undefined; // only affects compile-time type
}
}
`
Proper cleanup requires explicit delete statements in an afterEach hook:
`typescript
// test-setup.ts
afterEach(() => {
delete globalThis.featureFlags;
delete globalThis.analytics;
delete globalThis.ENV;
});
`
TypeScript will still report errors if code accesses the deleted property, because the type declaration remains in scope. Wrapping the delete in a type guard silences this:
`typescript
afterEach(() => {
if ("featureFlags" in globalThis) {
delete globalThis.featureFlags;
}
});
`
Alternatively, setting the property to undefined instead of deleting it preserves the type shape:
`typescript
afterEach(() => {
globalThis.featureFlags = undefined as any;
});
`
This approach requires changing the type declaration to allow undefined:
`typescript
declare global {
interface globalThis {
featureFlags?: {
enableDarkMode: boolean;
enableBetaFeatures: boolean;
};
}
}
`
Now every access requires an optional chain or existence check:
`typescript
if (globalThis.featureFlags?.enableDarkMode) {
// safe access
}
`
The choice between deletion and undefined depends on whether the codebase prefers explicit existence checks or assumes the property is always present during normal operation.
When NOT to Use globalThis: Better Alternatives for Most Use Cases
Typing globalThis addresses a tactical problem but does not solve the strategic issue of global state. Most applications benefit from alternatives that avoid polluting the global namespace.
Module-Scoped Constants
Configuration that never changes at runtime belongs in a module:
`typescript
// config.ts
export const config = {
apiUrl: import.meta.env.VITE_API_URL,
enableAnalytics: import.meta.env.VITE_ENABLE_ANALYTICS === "true"
};
`
This provides the same centralized access without touching globalThis. TypeScript infers the exact types, and tree shaking can remove unused properties in production builds. For more patterns around modern TypeScript library configuration, see Create a Modern TypeScript/JavaScript Library for 2023.
Dependency Injection
Services like analytics clients belong in dependency containers:
`typescript
// services.ts
export class Analytics {
track(event: string, properties: Record): void {
// implementation
}
}
export const analytics = new Analytics();
`
`typescript
// component.ts
import { analytics } from "./services";
analytics.track("pageview", { path: "/home" });
`
This supports testing through mock injection and avoids singleton globals. The pattern scales to complex applications without namespace conflicts.
Context Objects
React, Vue, and other frameworks provide context mechanisms for shared state:
`typescript
// feature-flags-context.tsx
const FeatureFlagsContext = createContext({
enableDarkMode: false,
enableBetaFeatures: false
});
export function FeatureFlagsProvider({ children }) {
const [flags, setFlags] = useState(defaultFlags);
useEffect(() => {
fetch("/api/feature-flags")
.then(res => res.json())
.then(setFlags);
}, []);
return (
{children}
);
}
export const useFeatureFlags = () => useContext(FeatureFlagsContext);
`
Components consume flags through the hook, and the framework handles the scoping. This eliminates global pollution and simplifies testing by allowing test wrappers to provide mock values.
When globalThis Is Actually Correct
The pattern remains valid for:
- Integrating third-party scripts that expect global names (analytics vendors, error trackers)
- Exposing debug utilities to the browser console during development
- Bridging module code and legacy non-module scripts in a migration period
- Polyfilling missing global APIs in older environments
If the global property ships to production, the declaration should include comments explaining why alternatives were rejected:
`typescript
declare global {
interface globalThis {
/**
* Segment analytics instance.
* Global assignment required by vendor SDK.
* See: https://segment.com/docs/connections/sources/catalog/libraries/website/javascript/
*/
analytics: SegmentAnalytics;
}
}
`
This prevents future engineers from assuming the global was added carelessly. For guidance on structuring cross-cutting concerns like correlation IDs in distributed systems, see Correlation IDs in AI Agent Architectures.
Frequently Asked Questions
Can I declare multiple properties on globalThis in separate files?
Yes. TypeScript merges all declarations of the same interface across files. Each file can extend globalThis with different properties, and the compiler combines them into a single type. This works as long as the properties do not conflict.
Why does my globalThis declaration not work in a .ts file?
The file likely has no import or export statements, making it a script instead of a module. Script files cannot use declare global. Add export {} to force module context, or move the declaration to a .d.ts file.
How do I prevent accidental writes to typed global properties?
Use readonly in the declaration:
`typescript
declare global {
interface globalThis {
readonly featureFlags: {
enableDarkMode: boolean;
};
}
}
`
This prevents assignment after initialization:
`typescript
globalThis.featureFlags = newFlags; // Error: Cannot assign to 'featureFlags'
`
Does globalThis typing work with strictNullChecks enabled?
Yes. The compiler enforces nullability based on the declared type. If the declaration does not include undefined, TypeScript assumes the property is always present. If runtime code might delete the property, add ? to the declaration or union with undefined.
What happens if two libraries declare the same global property with different types?
TypeScript reports an error when merging incompatible types. If library A declares globalThis.config: { timeout: number } and library B declares globalThis.config: { retries: number }, the compiler cannot merge them. The developer must declare a combined type that satisfies both libraries or file a bug with the library maintainers.
Conclusion
Declaration merging makes globalThis properties type-safe without runtime cost. The pattern works across all environments when using globalThis instead of window or global, and it integrates with existing toolchains without configuration changes. The technique fills a narrow gap: code that already relies on global state and cannot migrate to module-scoped alternatives.
Most new code should avoid globalThis entirely. Module exports, dependency injection, and framework context APIs provide better isolation and testability. When globals are unavoidable, proper TypeScript declarations prevent the most common failure mode: silent any types that surrender error checking at the exact boundary where runtime bugs cluster. For broader context on modern TypeScript tooling ecosystems, see Biome vs oxlint Comparison 2026.









Top comments (0)