isolatedModules and verbatimModuleSyntax Explained
Two compiler flags that keep a library safe under single-file transpilers: what each rejects, how they prevent accidental runtime imports, and how to migrate a codebase.
A library builds cleanly with tsc, then somebody switches the build to esbuild for speed and a consumer reports a runtime failure:
SyntaxError: The requested module './types.js' does not provide an export named 'ClientOptions'
Nothing about the source changed. What changed is that the new build compiles one file at a time and cannot know that ClientOptions was a type. Two compiler flags make that class of failure impossible to introduce.
Root Cause
tsc compiles a program: it has type information for every file at once, so it can tell that a re-exported name is a type and elide it from the output. Single-file transpilers — esbuild, SWC, Babel — compile each file in isolation with no cross-file type knowledge, so they must guess, and they guess by preserving what they see.
isolatedModules restricts your source to constructs whose emit does not depend on that missing information. verbatimModuleSyntax goes further: it makes tsc itself emit import and export statements verbatim, removing only those explicitly marked type, so the output is identical regardless of which tool produced it.
Minimal Reproduction
// src/types.ts
export interface ClientOptions { baseUrl: string; retries?: number; }
// src/index.ts — re-export without the type keyword
export { ClientOptions } from "./types.js";
export { createClient } from "./client.js";
npx tsc && grep "ClientOptions" dist/index.js || echo "tsc elided the type re-export"
npx esbuild src/index.ts --format=esm --outfile=/tmp/esbuild-out.mjs
grep "ClientOptions" /tmp/esbuild-out.mjs && echo "esbuild preserved it — runtime error follows"
tsc elided the type re-export
export { ClientOptions } from "./types.js";
esbuild preserved it — runtime error follows
Two tools, one source file, two different outputs. Only one of them runs.
What Each Flag Enforces
Step-by-Step Fix
1. Enable isolatedModules and fix what it reports
"compilerOptions": {
+ "isolatedModules": true,
"declaration": true
}
npx tsc --noEmit
src/index.ts:4:10 - error TS1205: Re-exporting a type when 'isolatedModules' is enabled
requires using 'export type'.
Every diagnostic has exactly one correct fix, which makes the migration mechanical:
- export { ClientOptions } from "./types.js";
+ export type { ClientOptions } from "./types.js";
export { createClient } from "./client.js";
2. Replace const enums and namespace merging
isolatedModules also rejects const enum used across files, because inlining its values requires whole-program knowledge:
- export const enum LogLevel { Debug, Info, Warn }
+ export const LogLevel = { Debug: 0, Info: 1, Warn: 2 } as const;
+ export type LogLevel = (typeof LogLevel)[keyof typeof LogLevel];
The replacement is a real runtime object plus a type of the same name, which behaves identically for consumers and compiles under any tool.
3. Enable verbatimModuleSyntax and mark type-only imports
"compilerOptions": {
"isolatedModules": true,
+ "verbatimModuleSyntax": true,
- import { ClientOptions, createClient } from "./client.js";
+ import type { ClientOptions } from "./client.js";
+ import { createClient } from "./client.js";
Expected result: the emitted JavaScript contains the value import and not the type import — and now the same is true whichever tool compiled it.
HAZARD PREVENTION
Symptom: After enabling
verbatimModuleSyntax, a package that previously worked now fails at runtime withERR_REQUIRE_ESMin a CommonJS consumer.Root cause: An import of a type from an ESM-only module was previously elided by the compiler and is now preserved, so the CommonJS build attempts to load a module it never used to touch.
Fix: Mark the import
import typeso it is removed again. This is the flag doing its job — the previous behaviour depended on an elision that a different compiler would not have performed.
4. Add an automated check that both stay on
{
"scripts": {
"check:flags": "node -e \"const c=require('./tsconfig.build.json').compilerOptions; if(!c.isolatedModules||!c.verbatimModuleSyntax) { console.error('both flags must remain enabled'); process.exit(1); } console.log('flags ok')\""
}
}
Verification
# the same source produces equivalent output under both toolchains
npx tsc --outDir /tmp/tsc-out
npx esbuild src/index.ts --format=esm --outfile=/tmp/esbuild-out.mjs
diff <(grep -oE 'from "[^"]+"' /tmp/tsc-out/index.js | sort -u) \
<(grep -oE 'from "[^"]+"' /tmp/esbuild-out.mjs | sort -u) \
&& echo "module graphs agree"
# no type-only import survives as a runtime import
grep -n "import {" dist/index.mjs
Expected: module graphs agree, and an import list containing only modules with runtime values.
Edge Cases / Gotchas
export *from a module with only types becomes an error. UnderisolatedModulesit cannot be resolved per-file; replace it with explicitexport type { … }.- Decorator metadata needs whole-program information.
emitDecoratorMetadatais incompatible with single-file transpilation, so a library using it cannot fully benefit from these flags. - A dependency’s
const enumis still inlined bytsc. Your own code is constrained, but a dependency shipping one can still produce tool-dependent output;preserveConstEnumsmitigates it partially. import typeon a side-effect import removes the side effect. If a module was imported for its registration behaviour rather than its types, marking ittypesilently deletes that behaviour.- Editors may auto-import without the keyword. Configure the editor’s TypeScript preference to prefer type-only imports, or the migration regresses one auto-import at a time.
Both flags are worth enabling even on a project that will never switch away from tsc, because the constraint they impose is a useful one on its own: source in which every import states whether it exists at runtime is easier to reason about, easier to tree-shake, and immune to a whole category of surprise when the build tooling eventually changes. The migration cost is a few hours of mechanical edits, paid once.
Frequently Asked Questions
What problem does isolatedModules actually solve?
It restricts your source to constructs compilable one file at a time, so single-file transpilers that never see the whole program cannot produce broken output.
How is verbatimModuleSyntax different from isolatedModules?
isolatedModules restricts what you may write; verbatimModuleSyntax changes what is emitted, removing only type-marked imports and exports so the module graph is predictable.
Do these flags change my published JavaScript?
verbatimModuleSyntax does — previously elided imports may now be preserved. isolatedModules changes nothing on its own.
Is the type keyword required on every type-only import?
Under verbatimModuleSyntax, yes for anything that should not exist at runtime; without it the import is emitted and the module is loaded.
Can these flags be adopted incrementally?
Yes — enable isolatedModules first and fix its mechanical diagnostics, then verbatimModuleSyntax, whose behaviour assumes those type keywords exist.
Related
- Optimizing tsconfig.json for Library Distribution — where these flags sit among the options that ship.
- Migrating from tsc to esbuild for Faster Builds — the migration that makes both flags necessary rather than optional.
- Implementing the sideEffects Flag Correctly — why a preserved type import can defeat elimination.