Typing Libraries That Export Global Variables in TypeScript
Introduction
Many legacy libraries and browser-oriented SDKs expose themselves by attaching a single object or multiple symbols to the global scope (window, globalThis, or a host-provided object). As an intermediate TypeScript developer, you've probably encountered a package that expects consumers to use a global variable rather than import an ES module. Typing these libraries so they are ergonomic, safe, and easy to consume is a subtle but valuable skill.
In this tutorial you'll learn how to create accurate TypeScript declarations for libraries that export global variables. We'll cover declaration file formats (.d.ts), strategies for pollution-free augmentation, UMD wrappers, module/ambient hybrid typing, declaration merging, and techniques to maintain backward compatibility while enabling strong typing for modern consumers.
By the end you'll be able to:
- Decide between ambient global declarations and module-based typings
- Author robust .d.ts files for both old-school script tags and modern bundlers
- Safely augment global interfaces without breaking LIB consumers
- Provide type-safe adapters and runtime guards for global APIs
We'll also include many practical examples, step-by-step instructions, troubleshooting tips, and advanced optimizations to ensure your typed global library is maintainable and robust.
Background & Context
Historically, distributing JavaScript as a single script that assigns to window.MyLib was common for browser SDKs and widgets. These libraries are still widely used in analytics, ads, feature flags, and third-party SDKs. TypeScript consumers expect typings either in a module or ambient declarations. Getting the shape and consumption patterns right matters for editor auto-complete, type checking, and preventing silent runtime errors.
There are several typing idioms you will encounter:
- Ambient global declarations (declare global { ... }) which describe symbols on globalThis.
- UMD or hybrid typings that can be imported or accessed globally (declare namespace MyLib; export as namespace MyLib;).
- Augmenting existing built-in interfaces (adding properties to Window, WindowOrWorkerGlobalScope, or globalThis).
Correctly typed globals avoid confusion, preserve tree-shaking (when applicable), and make it easier to evolve your API safely.
For related design patterns around encapsulation and exports, see our guide on Typing Revealing Module Pattern Implementations in TypeScript.
Key Takeaways
- Understand the differences between ambient globals and module-based typings
- Use declare global wisely; prefer scoped augmentation for minimal plumbing
- Provide a UMD/hybrid .d.ts to support both script-tag consumers and importers
- Use declaration merging to give both namespaced and modular access
- Add runtime guards + typed adapter functions for safety
- Keep typing surface minimal and stable to ease future changes
Prerequisites & Setup
You should be comfortable with TypeScript basics: interfaces, ambient declarations (declare), modules, and declaration files (.d.ts). Have Node.js and TypeScript installed (recommended TS 4.5+). A sample project with a build tool (e.g., Rollup, esbuild, or tsc) will help for testing. Create a folder structure like:
- src/
- dist/
- index.js (your runtime JS built for distribution)
- index.d.ts (your typings)
Install TypeScript locally:
npm install --save-dev typescript
Initialize a tsconfig.json to test typings (you can use "tsc --init"). We'll show examples using "globalThis" and UMD-friendly patterns.
Main Tutorial Sections
1) Identify the Distribution Shape (100-150 words)
First, determine how consumers load your library: via script tag (window.MyLib), AMD, CommonJS, or ESM import. This influences the .d.ts layout. If you control the build, prefer shipping both an ESM module and a UMD build so both modern bundlers and legacy script users are covered. For pure global-only libraries, you'll author ambient declarations and add instructions for consumers to include a triple-slash reference or a script tag.
If you ship a hybrid, provide a UMD header in your .d.ts:
export as namespace MyLib;
export interface Config { url?: string }
export function init(cfg?: Config): void;This tells TypeScript that when consumed as a script, a global namespace MyLib exists; when imported it behaves like a module.
For patterns of safe module encapsulation and API shaping, our article on Typing Module Pattern Implementations in TypeScript — Practical Guide is useful.
2) Authoring a Simple Global .d.ts (100-150 words)
For a library that simply attaches a single object to globalThis (window.MySDK), create an ambient declaration file:
// index.d.ts
declare namespace MySDK {
interface Options { apiKey?: string }
function configure(opts?: Options): void
function track(event: string, payload?: any): void
}
export as namespace MySDK;
export = MySDK;Key parts:
- declare namespace: describes the object shape
- export as namespace: tells TypeScript there is a global MySDK when used as a script
- export = MySDK: supports CommonJS/require style imports
This simple layout covers many legacy use cases while providing type information for editors.
3) Augmenting Global Interfaces Safely (100-150 words)
Sometimes libraries attach to window directly (window.myFeature = { ... }). Instead of a global namespace, augment the Window or globalThis interface:
// augment.d.ts
declare global {
interface Window { myFeature?: MyFeature }
interface MyFeature { doThing(x: number): string }
}
export {}Exporting an empty object (export {}) marks the file as a module; the declare global block augments the global scope. This pattern avoids creating new global symbols and integrates with host-provided objects. Note: prefer adding optional properties (myFeature?) unless you're absolutely sure the property exists at runtime.
For more on delivering safe modular interfaces and reducing global pollution, see Typing Revealing Module Pattern Implementations in TypeScript.
4) Hybrid Typings: Module + Global (100-150 words)
To support both importers and script-tag users, authors often use a hybrid pattern.
// hybrid.d.ts
declare namespace Widget {
interface API { render(el: HTMLElement): void }
}
declare global { const Widget: Widget.API }
export as namespace Widget;
export = Widget;This provides:
- a global const Widget for script consumers
- module compatibility via export = for require() consumers
- type-safe API surface for importers if you later add an index.d.ts that exports types
Test hybrid typings by consuming code in both an ambient script demo and a module-based test file.
5) Using Declaration Merging for Extensibility (100-150 words)
Declaration merging lets other packages add methods or events to your global API without touching your source. Design your base types to be merge-friendly:
// core.d.ts
declare namespace MapLib {
interface Controls { zoomIn(): void }
interface MapOptions { center?: [number, number] }
}
export as namespace MapLib;
export = MapLib;
// plugin.d.ts (third-party)
declare namespace MapLib {
interface Controls { addCustomControl(name: string): void }
}This pattern is common for plugin-based SDKs. Document merge points clearly so plugin authors know which interfaces to extend. For composing behaviors using class-based mixins, consult Typing Mixins with ES6 Classes in TypeScript — A Practical Guide.
6) Runtime Guards and Typed Adapters (100-150 words)
Static typings are valuable, but runtime shape checks prevent hard-to-debug errors. Create a small runtime guard and an adapter that turns an untyped global into a typed facade:
function isMySDK(x: any): x is MySDK.API {
return x && typeof x.configure === 'function' && typeof x.track === 'function';
}
export function getGlobalSDK(): MySDK.API | null {
const g = (globalThis as any).MySDK;
return isMySDK(g) ? g : null;
}Expose this small helper so consumers can safely look up the global and fall back or throw useful errors. For libraries that wrap or adapt mis-matched runtime shapes, see ideas in Typing Adapter Pattern Implementations in TypeScript — A Practical Guide.
7) UMD, AMD, and CommonJS Considerations (100-150 words)
If you ship builds for multiple runtimes, your declaration must reflect that. Use the UMD header at top of the .d.ts:
// umd.d.ts
export as namespace MyLib;
declare namespace MyLib { /* ... */ }
export = MyLib;This covers script tags and require() usage. For AMD/UMD builds, ensure your bundler outputs a global name and your package.json fields (main, module, types, browser) point to the correct artifacts. Test consumers by creating small example projects that import via require, import, and via script tag to exercise the different resolution paths.
8) Handling Multiple Global Symbols and Collisions (100-150 words)
Some libraries add multiple globals or nested namespaces: window.Lib, window.Lib.Utils, window.Lib.UI. Avoid accidental collisions by:
- documenting exact global names and recommended practices
- adding optional chaining to types where presence is not guaranteed
- namespacing types inside a single top-level declare namespace
Example:
declare namespace Lib {
namespace Utils { function hash(x: string): string }
namespace UI { function mount(el: HTMLElement): void }
}
export as namespace Lib;
export = Lib;This centralizes the declarations and makes it clear which parts may be missing at runtime.
9) Testing and Validation of Typings (100-150 words)
Always include a typed test-suite for your declarations. Create a "types.test.ts" (or .d.ts) that imports your package in the supported ways and runs quick compile-time checks:
// test-import.ts
import MyLib = require('./dist');
MyLib.configure({ apiKey: 'x' });
// test-global.ts
declare const MyLib: typeof import('./dist');
MyLib.track('event');Run tsc --noEmit -p tsconfig.json to validate. Add these checks in CI so regressions are detected early. For libraries that use events, combining these tests with typed event-emitter patterns can catch interface mismatches—see Typing Libraries That Use Event Emitters Heavily.
Advanced Techniques (200 words)
When your global-exporting library becomes complex, consider these advanced approaches:
-
Split your declarations: publish a minimal ambient declaration for script consumers and an extended module-based package for importers. Consumers who import gain richer types, while script users keep a small footprint.
-
Conditional types & overloads: if your API accepts both objects and simple primitives, use overloads and conditional types to refine return types. For example, typed feature flags can return boolean | T depending on the configuration.
-
Proxy-based wrappers: create a small runtime Proxy that validates access and throws helpful errors; pair this with a minimal interface so devs see the right members. For ideas about interception and validation patterns, check Typing Proxy Pattern Implementations in TypeScript.
-
Provide an adapter layer: for global-legacy consumers, deliver a tiny adapter that exposes the global symbol as an ES module. This helps bundlers and modern tooling while preserving the global export for old consumers. The adapter approach is related to Typing Adapter Pattern Implementations in TypeScript — A Practical Guide.
-
Runtime shape migration: if you need to evolve an API, provide a compatibility layer that detects old shapes and maps them to the new typed interface. Combine this with feature flags and deprecation warnings.
Best Practices & Common Pitfalls (200 words)
Dos:
- Prefer minimal global augmentation—keep the global surface small.
- Use optional properties on Window/globalThis unless presence is guaranteed.
- Provide runtime guards and clear error messages for missing globals.
- Ship a small example that demonstrates script-tag usage and module imports.
- Add typed tests and CI checks to prevent breaking typing changes.
Don'ts:
- Don’t over-annotate with very broad "any" types to silence errors—this defeats the purpose of TS.
- Avoid polluting the global namespace with many top-level names; favor a single namespace.
- Don’t assume user's environment always has window (workers, Node with jsdom). Use globalThis where possible.
Common pitfalls:
- Forgetting to add "export as namespace" for UMD consumers, causing script-tag users to lack typings.
- Publishing mismatch: types pointing to a file that doesn’t match the runtime shape—ensure the runtime and type surfaces match precisely.
- Overlapping names: if consumers already declare the same global symbol, ensure declaration merging is safe or provide an opt-in module path.
For guidance on related patterns that help minimize global surface and create safer interfaces, see Typing Revealing Module Pattern Implementations in TypeScript and Typing Decorator Pattern Implementations (vs ES Decorators) for decorating global behaviors safely.
Real-World Applications (150 words)
Global-exporting libraries remain common for:
- Browser SDKs and analytics scripts that load via script tag
- Feature-flagging and A/B test libraries attached to window
- Widgets that third-parties embed on web pages
- Legacy apps migrating to ES modules slowly
Example: an analytics vendor can ship a small global which buffers events until the main runtime loads. Typings should reflect the buffering behavior (e.g., queue arrays) and provide safe APIs for lookups and flush operations. For publisher-side plugin ecosystems (map libraries, charting tools), typed declaration merging enables safe extensibility—see Typing Module Pattern Implementations in TypeScript — Practical Guide.
If your global library interacts with events or callback-style APIs, check our related resources on Typing Libraries That Use Callbacks Heavily (Node.js style) and Typing Libraries That Use Event Emitters Heavily to see how to type asynchronous or evented interfaces.
Conclusion & Next Steps (100 words)
Typing libraries that export globals is a balancing act: you want ergonomic, accurate types without breaking legacy consumers. Start with a minimal ambient declaration, add UMD or hybrid typings for broader compatibility, and provide runtime guards and typed adapters. Test your declarations thoroughly and publish clear migration notes when evolving the API.
Next steps: add typed examples to your README, set up CI type checks, and consider publishing a small adapter module to ease adoption by modern bundlers. For advanced composition and interception techniques, explore Typing Proxy Pattern Implementations in TypeScript and Typing Adapter Pattern Implementations in TypeScript — A Practical Guide.
Enhanced FAQ
Q1: Should I always prefer module exports over global exports? A1: When you control the library, prefer ESM or CommonJS module exports for clarity, tree-shaking, and modern tooling. However, if you must support customers who embed a script tag (e.g., third-party integrations), provide a UMD build and a small global wrapper. Hybrid typings cover both cases.
Q2: What's the safest way to add a property to window without breaking others? A2: Use declare global with optional properties and a unique top-level namespace. For example: declare global { interface Window { MySDK?: MySDK.API } }. This keeps the global surface minimal and avoids immediate runtime assumptions.
Q3: How do I test that my .d.ts matches runtime behavior? A3: Write a small test project that consumes your distributed JS and imports or references the .d.ts. Use tsc --noEmit to catch typing mismatches. Also write runtime tests that load your built JS in a browser-like environment (e.g., Puppeteer or jsdom) to assert presence and behavior of global symbols.
Q4: Can I auto-generate typings from JSDoc? Is that sufficient? A4: JSDoc can be a good starting point and TypeScript can infer types when configured, but for complex global surfaces and overloads you’ll often need hand-written declarations. Auto-generated typings may miss subtle overloads, declaration merging spots, and nuanced module/global hybrid behaviors.
Q5: How do I evolve a global API without breaking consumers? A5: Provide deprecation shims, keep old symbols for a few releases, and document migration. Implement runtime adapters that map old shapes to new ones and include compile-time guidance in your typings (e.g., mark deprecated signatures with @deprecated JSDoc tags).
Q6: Should I use globalThis or window in typings? A6: Prefer globalThis in library typings because it works across browsers, workers, and Node (when polyfilled). Use Window for browser-only augmentations when you’re sure it’s browser-exclusive. Document constraints clearly.
Q7: How to handle third-party plugins that need to extend my global API? A7: Design extension points as interfaces that are safe to merge (e.g., interfaces Controls, Plugins). Document where plugin authors should augment. Use declaration merging and provide examples. For plugin patterns and mixin guidance, see Typing Mixins with ES6 Classes in TypeScript — A Practical Guide.
Q8: My library exposes both event callbacks and an emitter on the global. How to type that cleanly? A8: Expose a typed event interface with typed listener signatures and prefer a typed EventEmitter facade. If you need a global emitter, declare its type in the ambient namespace and export helper functions to subscribe safely. See Typing Libraries That Use Event Emitters Heavily for patterns and best practices.
Q9: Are there performance implications for typed adapters or runtime guards? A9: Runtime guards are tiny checks (typeof or presence checks) and add negligible overhead. If you wrap global objects with Proxies or heavy validation in hot paths, measure and optimize. Use lazy adapters to avoid initialization cost until the API is actually used. For interception patterns and performance tradeoffs, consult Typing Proxy Pattern Implementations in TypeScript.
Q10: How should I document my global API so consumers (script and module) understand usage? A10: Provide clear README examples for both script-tag usage and import-based usage, include a types/typings section, and ship small runnable examples in your repository. Document recommended migration paths and include typed snippets so editors can show correct completions.
