TypeScript 6.0 --moduleDetection Force: Why Your Ambient Declarations Broke and How to Fix Them
The moduleDetection: force option breaks ambient declarations by treating all .d.ts files as modules. Learn the three detection modes, why this change matters, and two proven patterns to fix your type definitions.
Most TypeScript 6.0 upgrade failures stem from a single tsconfig setting that teams enabled without understanding its cascading effects. The moduleDetection: "force" option breaks ambient type declarations across entire codebases because it changes how TypeScript determines whether a file is a script or a module. What worked in TypeScript 5.x—global type augmentations, ambient namespace extensions, third-party typings—suddenly throws duplicate identifier errors or stops augmenting globals entirely.
The failure mode appears when developers see type definitions that once declared global types now create isolated module scopes instead. Declaration files that previously extended Window or added global utility types silently stop working. The compiler treats every .d.ts file as a module by default, which means declare global becomes mandatory where it wasn't before.
%% alt: problem flow showing ambient declarations becoming isolated modules
flowchart LR
A("TypeScript 6.0<br/>with force mode")
B("reads globals.d.ts<br/>with ambient declarations")
C("compiler treats file<br/>as isolated module")
D("global augmentations<br/>silently ignored")
A --> B
B --> C
C --> D
style D stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
The solution requires understanding the three moduleDetection modes and applying one of two fix patterns: adding explicit export {} statements to preserve module semantics while wrapping globals in declare global blocks, or using declare module wrapper syntax for entire files. Both approaches restore the expected behavior, but the choice depends on whether the file needs to export types or purely augment the global scope.
%% alt: solution flow showing proper module detection configuration
flowchart LR
A("TypeScript 6.0<br/>with force mode")
B("reads globals.d.ts<br/>with export statement")
C("compiler creates<br/>module scope")
D("declare global block<br/>augments Window")
E("global types<br/>available everywhere")
A --> B
B --> C
C --> D
D --> E
style E stroke:#34d399,fill:#0b3b2e,color:#d1fae5
Key Takeaways
- The
moduleDetection: "force"option treats all files as modules by default, breaking ambient declarations that rely on script-level global scope. - TypeScript determines module status by the presence of import/export statements; force mode bypasses this heuristic and always assumes module semantics.
- Ambient declarations in module-scope files must use
declare globalblocks to augment the global namespace instead of top-level declare statements. - Migration requires auditing all .d.ts files for global augmentations and wrapping them appropriately or adding explicit export markers.
- The force mode exists to prevent accidental global pollution in modern ESM codebases, but requires careful configuration of legacy type definitions.
Understanding moduleDetection: The Three Modes Explained
TypeScript determines whether a file is a script or a module based on the presence of top-level import or export statements. A script runs in global scope—every declaration becomes globally visible. A module creates its own scope—declarations remain isolated unless explicitly exported. This distinction is critical because it changes how declare statements behave.
The moduleDetection compiler option controls this detection mechanism with three possible values: "auto", "legacy", and "force". Each mode implements different rules for when TypeScript treats a file as a module versus a script.
Auto mode—the default in TypeScript 5.0 and later—uses modern heuristics. A file becomes a module if it contains import or export statements, or if it has a "type": "module" declaration in its nearest package.json. Files without these markers remain scripts. This approach works well for codebases transitioning to ESM because it respects package.json module boundaries while allowing script-style .d.ts files to augment globals without boilerplate.
Legacy mode reverts to pre-5.0 behavior. TypeScript only looks at import/export statements and ignores package.json entirely. This mode exists for backward compatibility with projects that rely on the old detection rules, but teams rarely need it unless maintaining TypeScript 4.x codebases.
Force mode treats every file as a module regardless of its contents or package.json settings. Even a .d.ts file with no imports or exports becomes a module. The implication here is that all global declarations must be wrapped in declare global blocks or the compiler will isolate them to the file's module scope.
%% alt: three moduleDetection modes and their file treatment rules
flowchart TD
A("file without<br/>import/export")
B("legacy mode")
C("auto mode")
D("force mode")
E("treated as script<br/>global scope")
F("check package.json<br/>type field")
G("treated as module<br/>isolated scope")
H("ESM package")
I("CJS package")
A --> B
A --> C
A --> D
B --> E
C --> F
D --> G
F --> H
F --> I
H --> G
I --> E
style E stroke:#7c9cf0,fill:#142544,color:#eaf2ff
style G stroke:#7c9cf0,fill:#142544,color:#eaf2ff
How force Mode Changes Script vs Module Detection
The breaking change happens because force mode eliminates the script-file escape hatch that many type definitions depend on. In TypeScript 5.x with auto mode, developers could create a globals.d.ts file with ambient declarations like declare const API_KEY: string and TypeScript would treat the file as a script, making API_KEY globally visible. The file never needed an export statement because the absence of imports/exports signaled script intent.
Force mode removes this assumption. The same globals.d.ts file now becomes a module with its own isolated scope. The declare const API_KEY: string statement creates a declaration that only exists within that module. Other files can't see API_KEY unless globals.d.ts explicitly exports it—but ambient declarations aren't meant to be imported, they're meant to augment the global namespace.
This matters because modern JavaScript tooling increasingly defaults to ESM semantics. Build tools like Vite and frameworks like Next.js set "type": "module" in package.json by default. When TypeScript runs in auto mode within these projects, it correctly treats files as modules and developers learn to use declare global for global augmentations. Force mode exists to enforce this discipline even in projects that haven't migrated to ESM yet.
The failure mode here is subtle but expensive. Teams upgrading to TypeScript 6.0 enable force mode for consistency with their build tooling, then discover that dozens of .d.ts files scattered across the codebase stop working. Type definitions for environment variables, global utility functions, and third-party library augmentations all break simultaneously. The compiler doesn't warn that it's treating files differently—it just silently changes scope semantics.
%% alt: comparison of file scope treatment between auto and force modes
flowchart LR
subgraph Auto["auto mode"]
A1("globals.d.ts<br/>no import/export")
A2("treated as script")
A3("declarations<br/>globally visible")
end
subgraph Force["force mode"]
B1("globals.d.ts<br/>no import/export")
B2("treated as module")
B3("declarations<br/>isolated to file")
end
A1 --> A2
A2 --> A3
B1 --> B2
B2 --> B3
style A3 stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style B3 stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
The Breaking Change: When Ambient Declarations Become Module Declarations
The practical impact becomes clear with a concrete example. Consider a typical env.d.ts file that declares environment variables:
// env.d.ts - broken in force mode
declare namespace NodeJS {
interface ProcessEnv {
DATABASE_URL: string;
API_KEY: string;
NODE_ENV: 'development' | 'production' | 'test';
}
}In auto mode without a package.json "type": "module" marker, this file works perfectly. The compiler treats it as a script because it lacks imports or exports. The namespace augmentation merges with the global NodeJS namespace from @types/node, and process.env.DATABASE_URL becomes type-safe everywhere in the codebase.
Switching to force mode breaks this silently. TypeScript now treats env.d.ts as a module. The namespace declaration creates a local NodeJS namespace within the module's scope instead of augmenting the global one. Other files importing from @types/node see the original ProcessEnv interface without the custom properties. The compiler never warns that the augmentation failed—it just doesn't take effect.
The same pattern breaks Window augmentations, third-party library extensions, and custom global utility types:
// globals.d.ts - also broken in force mode
interface Window {
gtag: (command: string, ...args: any[]) => void;
dataLayer: any[];
}
declare function setupAnalytics(): void;
type JSONValue =
| string
| number
| boolean
| null
| JSONValue[]
| { [key: string]: JSONValue };This code worked in legacy TypeScript projects because the file lived in global scope. Force mode isolates it. The Window interface augmentation doesn't merge with the global Window type. The setupAnalytics function isn't callable from other modules. The JSONValue utility type doesn't exist outside this file. Each declaration becomes a module-local definition that serves no purpose.
The distinction between script-scope and module-scope declarations explains why this matters for type definitions specifically. Regular TypeScript files always contain imports or exports—they're naturally modules. Declaration files exist purely to provide type information, so developers often omit imports/exports entirely. The assumption that "no imports/exports means global scope" held true until force mode changed the rules.
Fix Pattern 1: Adding Empty export {} Statements
The first fix pattern explicitly marks the file as a module while wrapping global augmentations in declare global blocks. This approach works when the file needs to export types in addition to augmenting globals, or when developers want to be explicit about module semantics.
Start by adding an empty export statement at the bottom of the file. This tells TypeScript "yes, this is definitely a module" and eliminates any ambiguity. Then wrap each global augmentation in a declare global block:
// env.d.ts - fixed with declare global
declare global {
namespace NodeJS {
interface ProcessEnv {
DATABASE_URL: string;
API_KEY: string;
NODE_ENV: 'development' | 'production' | 'test';
}
}
}
export {};The empty export creates module scope, but the declare global block explicitly targets the global namespace for augmentation. The compiler merges the ProcessEnv properties with the global NodeJS.ProcessEnv interface, restoring the original behavior. Other files see DATABASE_URL and API_KEY without importing anything from env.d.ts.
%% alt: fix pattern flow using empty export and declare global
flowchart LR
A("add empty export<br/>to .d.ts file")
B("wrap declarations<br/>in declare global")
C("compiler creates<br/>module scope")
D("global block augments<br/>global namespace")
E("types available<br/>everywhere")
A --> B
B --> C
C --> D
D --> E
style D stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
style E stroke:#34d399,fill:#0b3b2e,color:#d1fae5
The same pattern fixes Window augmentations and utility types:
// globals.d.ts - fixed with declare global
declare global {
interface Window {
gtag: (command: string, ...args: any[]) => void;
dataLayer: any[];
}
function setupAnalytics(): void;
type JSONValue =
| string
| number
| boolean
| null
| JSONValue[]
| { [key: string]: JSONValue };
}
export {};This approach has an advantage when files mix global augmentations with exported types. The file can export specific types while still augmenting globals:
// types.d.ts - mixing exports and global augmentations
declare global {
interface Window {
__INITIAL_STATE__: AppState;
}
}
export interface AppState {
user: User | null;
theme: 'light' | 'dark';
}
export interface User {
id: string;
email: string;
role: 'admin' | 'user';
}Other modules import AppState and User explicitly, but Window.INITIAL_STATE remains globally accessible. The pattern makes the dual purpose explicit: this file both exports types and modifies global scope.
Fix Pattern 2: Using declare module Wrapper Syntax
The second fix pattern wraps the entire file in a declare module block targeting the global namespace. This approach works better for pure ambient declaration files that never export anything and exist solely to augment globals.
The syntax looks like this:
// env.d.ts - fixed with declare module wrapper
declare module globalThis {
namespace NodeJS {
interface ProcessEnv {
DATABASE_URL: string;
API_KEY: string;
NODE_ENV: 'development' | 'production' | 'test';
}
}
}The declare module globalThis statement tells TypeScript "everything in this block augments the global scope" without requiring declare global wrappers inside. The compiler treats the entire file as a global augmentation regardless of module detection mode. This pattern has less boilerplate when the file contains many global declarations.
%% alt: fix pattern flow using declare module globalThis wrapper
flowchart LR
A("wrap entire file<br/>in declare module")
B("target globalThis<br/>namespace")
C("compiler treats<br/>as global augmentation")
D("all declarations<br/>augment globals")
E("types available<br/>everywhere")
A --> B
B --> C
C --> D
D --> E
style C stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
style E stroke:#34d399,fill:#0b3b2e,color:#d1fae5
The same pattern works for Window augmentations and utility types:
// globals.d.ts - fixed with declare module wrapper
declare module globalThis {
interface Window {
gtag: (command: string, ...args: any[]) => void;
dataLayer: any[];
}
function setupAnalytics(): void;
type JSONValue =
| string
| number
| boolean
| null
| JSONValue[]
| { [key: string]: JSONValue };
}This approach has an aesthetic advantage for large ambient declaration files. A single wrapper at the top makes the file's purpose obvious—it augments globals and nothing else. Developers don't need to remember to add export {} at the bottom or wrap each declaration individually.
The tradeoff is that files using this pattern can't export types. If the file needs to provide both global augmentations and exportable type definitions, the first pattern with declare global and export {} becomes necessary. Choose the wrapper syntax when the file is purely ambient, and the mixed syntax when the file needs dual purpose.
Migration Strategy: Auditing Your .d.ts Files
Migrating a codebase to force mode requires systematic auditing of every .d.ts file. The goal is to identify files that make global augmentations and apply one of the two fix patterns before enabling force mode in tsconfig.json.
Start by searching for files that declare globals without import/export statements:
# Find .d.ts files without imports or exports
rg --type-add 'dts:*.d.ts' --type dts -L 'import|export' --files-with-matchesThis command finds declaration files that don't contain the words "import" or "export" anywhere. These files likely rely on script-scope behavior and will break under force mode. Each file needs manual review because some might contain only type aliases or interfaces meant to be imported explicitly.
%% alt: migration audit workflow from finding files to applying fixes
flowchart LR
A("search codebase for<br/>.d.ts without imports")
B("review each file<br/>for global augmentations")
C("file augments globals")
D("file only defines types")
E("apply declare global<br/>or module wrapper")
F("leave unchanged<br/>will auto-import")
A --> B
B --> C
B --> D
C --> E
D --> F
style E stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style F stroke:#7c9cf0,fill:#142544,color:#eaf2ff
For each file found, look for these patterns that indicate global augmentations:
- Interface declarations extending Window, Document, or other global types
- Namespace declarations extending NodeJS, JSX, or library namespaces
- Top-level function or variable declarations meant to be globally accessible
- Type utility definitions used without imports across the codebase
Files matching these patterns need fixes. Apply the declare global pattern if the file will export types, or the declare module globalThis wrapper if it's purely ambient.
Files that only define interfaces or types without augmenting globals don't need changes. TypeScript will continue to make their exports available for import. The absence of imports/exports in these files is irrelevant—they become modules under force mode, but that's the intended behavior.
After fixing files, enable force mode in tsconfig.json:
{
"compilerOptions": {
"moduleDetection": "force",
"module": "ESNext",
"moduleResolution": "bundler"
}
}Run tsc --noEmit to verify the changes. The compiler will catch any remaining files that need fixes by throwing "Cannot find name" errors for previously global types or "Duplicate identifier" errors where augmentations didn't merge correctly.
The migration strategy works because it makes global augmentations explicit before changing compiler behavior. Teams that enable force mode first and then chase errors waste time debugging scope issues that could have been prevented with systematic auditing.
Should You Use moduleDetection: force in 2026?
The decision to use force mode depends on whether the codebase runs in modern ESM tooling and whether teams want to prevent accidental global pollution. Projects using Vite, Next.js, or other ESM-first tools benefit from force mode because it aligns TypeScript's module detection with the build system's expectations. The compiler stops creating script-scope files that conflict with ESM semantics.
For these projects, force mode prevents a class of bugs where developers accidentally create global types when they meant to create module-scoped exports. A .d.ts file without imports or exports in auto mode becomes a script, making all its declarations global. Developers see the types working and don't realize they've polluted the global namespace. Force mode makes this mistake impossible—every file is a module, so global augmentations must be explicit.
The cost is the migration effort described above. Codebases with many .d.ts files need systematic auditing and fixes before enabling force mode. Projects with complex type definition patterns—especially those augmenting third-party libraries extensively—face higher migration costs. Teams must weigh the benefit of strict module semantics against the time investment required to update existing code.
Projects that haven't migrated to ESM tooling or that maintain large collections of ambient type definitions should consider staying with auto mode. The mode exists precisely to support codebases where the script/module distinction still matters. As long as package.json doesn't set "type": "module", auto mode provides script-scope behavior for .d.ts files without imports/exports, matching developer expectations from pre-ESM TypeScript.
That covers the essential patterns for handling moduleDetection: force in TypeScript 6.0. Apply these fixes to your ambient declarations and the upgrade becomes straightforward instead of a breaking change across the entire type system.
Frequently Asked Questions
Why does TypeScript have three different moduleDetection modes?
The three modes exist to balance backward compatibility with modern ESM semantics. Legacy mode preserves pre-5.0 behavior for old codebases, auto mode handles the ESM transition gracefully by respecting package.json, and force mode enforces strict module semantics for teams that want to prevent accidental global pollution.
Can I use force mode with third-party type definitions from DefinitelyTyped?
Yes, but third-party types from @types packages aren't affected by your tsconfig moduleDetection setting. DefinitelyTyped packages ship pre-compiled .d.ts files that follow their own module conventions. The force mode only applies to .d.ts files your project owns.
What happens if I mix declare global and declare module globalThis patterns?
Mixing both patterns works but creates unnecessary complexity. Each pattern achieves the same result—global augmentation in module-scope files. Choose one pattern consistently across the codebase to maintain readability and make the migration strategy obvious to other developers.
Does moduleDetection affect runtime behavior or only type checking?
The setting only affects TypeScript's type checking and doesn't change emitted JavaScript. However, it influences how the compiler interprets your code's module structure, which indirectly affects import/export resolution and type visibility during development.
Should I add moduleDetection: force to a new project starting today?
New projects using ESM tooling benefit from force mode because it prevents the script-scope escape hatch that leads to accidental global pollution. Set force mode in your initial tsconfig and establish the pattern of using declare global for all global augmentations from day one.