moduleDetection
Controls whether TypeScript treats each source file as a module or as a global script.
{
"compilerOptions": {
"moduleDetection": "auto" | "legacy" | "force"
}
}- Added in TypeScript 4.7
Parameters
| Parameter | Type | Description |
|---|---|---|
$"auto"optional | string= "auto" | Looks for import and export statements, and additionally treats a file as a module when the nearest package.json has "type": "module" under module: node16/nodenext, or when the file is a JSX file under jsx: react-jsx. |
$"legacy"optional | string | The same behaviour as TypeScript 4.6 and earlier: only import and export statements make a file a module. |
$"force"optional | string | Ensures that every non-declaration file is treated as a module, whether or not it contains an import or export. |
Errors
Under "legacy" or "auto", two script files that declare the same top-level name produce TS2451 "Cannot redeclare block-scoped variable" because they share one global scope.
Changelog
- PHP 4.7Option introduced, and "auto" became the default — package.json "type": "module" and react-jsx files now count as module evidence, which "legacy" restores to the pre-4.7 rule.
TypeScript sorts every source file into one of two buckets. A module has its own scope: its top-level declarations are private until exported. A global script dumps its top-level declarations into a single project-wide scope shared with every other script and with the DOM and Node type definitions. moduleDetection decides how that sorting happens.
When to reach for it
Set "force" in any project where files are never meant to be globals — anything bundled by Vite, esbuild, webpack or Rollup, and any ESM package. It removes the export {} line people add at the bottom of otherwise export-free files just to get scope isolation, and it stops a stray const config in one file from colliding with const config in another.
Set "legacy" only when upgrading a pre-4.7 codebase that broke on 4.7's smarter auto — typically a "type": "module" package whose plain .ts files were relying on being globals.
What trips people up
"force" applies to non-declaration files only. A .d.ts file still follows the old rule, so global type declarations keep working — but a hand-written globals.ts that declares shared values with no exports stops being visible anywhere else the moment you flip the switch. Move those declarations into a .d.ts, or export them properly.
Example
// tsconfig.json -> { "compilerOptions": { "moduleDetection": "force" } }
// src/report.ts — no import, no export, still a module under "force"
const LABEL = "report";
console.log("report label:", LABEL);
// src/invoice.ts — same name, no conflict: separate module scopes
const LABEL = "invoice";
console.log("invoice label:", LABEL);
// Under "legacy" (or "auto" without imports) both files are global scripts,
// and tsc reports on the second one:
// error TS2451: Cannot redeclare block-scoped variable 'LABEL'.When to use it
- Stop unrelated files colliding on the same top-level const name in a bundled front-end app
- Drop the trailing `export {}` from files that exist only for side effects
- Restore pre-4.7 detection while migrating a package that just added "type": "module"
Frequently asked questions
Why do I get "Cannot redeclare block-scoped variable" in two separate files?
Because both files are global scripts, not modules, so their top-level declarations land in one shared scope. Add an import or export to each file, or set "moduleDetection": "force" in compilerOptions so every non-declaration file gets its own scope.
Can I stop writing export {} at the bottom of my TypeScript files?
Yes — set "moduleDetection": "force" and every non-declaration file is a module regardless of its contents, which is exactly what the empty export was buying you.
Does moduleDetection change the emitted JavaScript?
Not directly, but it changes what a file is, which changes emit for that file: a script emits no module wrapper and no imports/exports, while a module emits under whatever `module` setting is in force.
More examples
The export {} workaround this option replaces
The output is the same in both configurations — that is the point. The `export {}` line changes nothing at runtime; it exists purely to change how the compiler classifies the file. Setting `moduleDetection` to `force` moves that decision into the config so the line can be deleted from every file that carries it.
// Without moduleDetection, this is the idiom that forces module scope.
// src/bootstrap.ts
const routes = ["/", "/login", "/dashboard"];
console.log("routes:", routes.join(", "));
export {}; // the only reason this line exists
// With "moduleDetection": "force" the export {} is unnecessary —
// bootstrap.ts is a module because of the setting, not because of a token
// in the file. Output is identical either way:
// routes: /, /login, /dashboardWhat auto adds on top of legacy
One file, one output line — `listening on http://127.0.0.1:8080` — but two different scopes. Under `auto`, another file writing `console.log(PORT)` fails to compile with TS2304 (cannot find name). Under `legacy`, it compiles, because both files are scripts sharing one global scope. Nothing in the file itself indicates which applies; the answer lives in package.json.
// package.json -> { "type": "module" }
// tsconfig.json -> { "compilerOptions": { "module": "nodenext" } }
// src/settings.ts — no import, no export anywhere in the file
const PORT = 8080;
const HOST = "127.0.0.1";
console.log(`listening on http://${HOST}:${PORT}`);
// "auto" (the default since 4.7): package.json says "type": "module",
// so settings.ts is a module and PORT/HOST are private to it.
// "legacy": no import or export token, so settings.ts is a global script
// and PORT/HOST join the shared global scope alongside every other script.
Discussion