TypeScript 6.0 Type-Only Imports Are Now Enforced: What verbatimModuleSyntax Actually Breaks in Real Codebases
The verbatimModuleSyntax flag eliminates elision guessing, but it breaks mixed import statements, re-exports, and side-effect modules in production codebases. Here's what actually fails and how to fix it.
Most TypeScript build failures after a major version upgrade stem from one assumption: the compiler will figure out which imports are types and which are runtime values. That assumption breaks the moment teams enable verbatimModuleSyntax in tsconfig.json. The flag eliminates the compiler's guesswork around import elision, but it does so by enforcing an explicit contract that existing codebases violate in subtle, expensive ways.
The failure mode here is subtle but expensive. A codebase that compiled cleanly under TypeScript 5.x throws hundreds of errors under 6.0 with verbatimModuleSyntax enabled. The errors point to mixed import statements, namespace re-exports, and side-effect modules that the compiler previously tolerated. Teams either spend days migrating every import, or they disable the flag and lose the build integrity it guarantees.
%% alt: Problem flow showing mixed imports silently elided
flowchart LR
A("Developer writes<br/>mixed import") --> B("Compiler guesses<br/>what to elide")
B --> C("Bundler receives<br/>mismatched output")
C --> D("Runtime imports<br/>missing exports")
style D stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
The fix requires understanding what verbatimModuleSyntax actually enforces: every import and export statement must declare its intent explicitly. If a statement imports types, it must use import type. If it imports runtime values, it must use import. If it does both, the statement must split into two separate lines. The compiler no longer guesses, which means the migration surfaces every ambiguous import in the codebase.
%% alt: Solution flow showing explicit type imports
flowchart LR
A("Developer writes<br/>mixed import") --> B("Compiler enforces<br/>explicit syntax")
B --> C("Bundler receives<br/>accurate output")
C --> D("Runtime imports<br/>match declarations")
style D stroke:#34d399,fill:#0b3b2e,color:#d1fae5
This distinction is critical. The problem is not that verbatimModuleSyntax is strict. The problem is that teams wrote ambiguous imports because the compiler accepted them, and now the compiler refuses to guess on their behalf.
Key Takeaways
verbatimModuleSyntaxeliminates import elision guessing by requiring explicitimport typeorimportsyntax for every statement.- Mixed imports that combine types and runtime values in a single statement fail compilation and must split into separate lines.
- Re-exports using
export * fromfail when the target module contains only types unless wrapped inexport type * from. - Side-effect modules that execute code on import require explicit
import "./module"syntax or the compiler treats them as dead code. - Build performance improves by 15-30% in large codebases because bundlers no longer parse elided type imports.
What verbatimModuleSyntax Actually Does (And Why It Exists)
The flag enforces a one-to-one mapping between TypeScript source and emitted JavaScript. When enabled, the compiler emits every import and export statement exactly as written, with one exception: statements prefixed with import type or export type disappear entirely. The compiler makes no other decisions about what to keep or remove.
This matters because TypeScript's default behavior guesses which imports are types based on how the code uses them. If a codebase imports a class but only uses it in a type annotation, the compiler elides the import during emit. If the same class appears in a runtime expression later, the compiler keeps the import. The logic works most of the time, but it breaks in three scenarios.
First, bundlers like esbuild and Vite perform their own dead-code elimination. When TypeScript elides an import that the bundler expects, the bundler throws an error or ships broken code. Second, circular dependencies create ambiguity. The compiler might elide an import in module A because module B provides the same symbol, but if module B imports from A, the runtime crashes. Third, re-exports compound the problem. A barrel file that re-exports types and values cannot signal its intent without explicit syntax.
%% alt: TypeScript import elision decision tree
flowchart TD
A("Import statement<br/>encountered") --> B{"verbatimModuleSyntax<br/>enabled?"}
B -->|No| C("Compiler analyzes<br/>usage context")
B -->|Yes| D{"Marked with<br/>import type?"}
C --> E{"Used in runtime<br/>expression?"}
E -->|Yes| F("Emit import")
E -->|No| G("Elide import")
D -->|Yes| H("Always elide")
D -->|No| I("Always emit")
style G stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
style H stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style I stroke:#34d399,fill:#0b3b2e,color:#d1fae5
style D stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
The implication here is that verbatimModuleSyntax shifts the burden of correctness from the compiler to the developer. Instead of analyzing usage, the compiler trusts the syntax. This makes builds deterministic but requires migration effort.
The flag also deprecates three older flags: importsNotUsedAsValues, preserveValueImports, and isolatedModules. Teams that combined those flags to approximate strict behavior can replace all three with verbatimModuleSyntax. The new flag is simpler because it enforces one rule: say what you mean.
The Breaking Changes: Real Codebase Failures
The most common failure is the mixed import statement. A line like import { User, type UserRole } from "./user" violates the rule because it combines a runtime value (User) and a type (UserRole) in one statement. The compiler throws error TS1286: "A type-only import can specify a default import or named bindings, but not both."
Here's a real example from a production codebase:
// Before: compiles under TypeScript 5.x
import { createUser, type User, type Role } from "./user";
const admin = createUser({ name: "Alice", role: "admin" });// After: required under verbatimModuleSyntax
import { createUser } from "./user";
import type { User, Role } from "./user";
const admin = createUser({ name: "Alice", role: "admin" });The fix is mechanical but tedious. Every mixed import must split into two lines: one for runtime values, one for types. Codebases with thousands of import statements face hours of manual refactoring or automated codemods.
The second failure is re-exports in barrel files. A file like index.ts that re-exports types and values using export * from "./user" compiles cleanly under default settings, but it throws error TS2305 under verbatimModuleSyntax: "Module has no exported member."
// Before: barrel file re-exports everything
export * from "./user";
export * from "./product";
// After: must separate type and value re-exports
export * from "./user";
export type * from "./user"; // Error: cannot export both
// Correct: split into separate statements
export { createUser, updateUser } from "./user";
export type { User, Role } from "./user";The error occurs because export * re-exports everything, including types. When verbatimModuleSyntax is enabled, the compiler cannot determine which symbols are types without explicit syntax. The fix requires listing every export individually or using export type * for type-only modules.
The third failure is side-effect imports. A statement like import "./polyfill" executes code but imports no symbols. Without verbatimModuleSyntax, the compiler emits the import as-is. With the flag enabled, the compiler treats it as dead code unless the module is explicitly marked with a side effect in package.json or the import uses explicit syntax.
// Before: side-effect import works implicitly
import "./initialize-sentry";
// After: compiler removes it unless marked
import "./initialize-sentry"; // Still works, but only if package.json declares itThe failure mode here is silent. The import disappears during emit, and the side effect never runs. Production apps lose initialization code, polyfills, or global patches without a compile-time error.
Migration Patterns: Fixing Mixed Import Statements
The migration requires separating every mixed import into two statements: one for values, one for types. The process is mechanical, but it surfaces architectural problems. A module that exports 20 types and 3 functions probably violates single-responsibility. The migration forces teams to confront that design.
%% alt: Migration flow for splitting mixed imports
flowchart LR
A("Parse import<br/>statement") --> B("Identify value<br/>vs type symbols")
B --> C("Generate two<br/>separate imports")
C --> D("Verify runtime<br/>correctness")
D --> E("Commit changes")
style C stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
style E stroke:#34d399,fill:#0b3b2e,color:#d1fae5
The codemod for this is straightforward. The TypeScript compiler API provides a visitor that identifies import declarations, checks whether they mix types and values, and rewrites them into separate statements. Here's a minimal example:
import ts from "typescript";
function splitMixedImport(node: ts.ImportDeclaration): ts.ImportDeclaration[] {
const clause = node.importClause;
if (!clause?.namedBindings || !ts.isNamedImports(clause.namedBindings)) {
return [node];
}
const values: ts.ImportSpecifier[] = [];
const types: ts.ImportSpecifier[] = [];
for (const specifier of clause.namedBindings.elements) {
if (specifier.isTypeOnly) {
types.push(specifier);
} else {
values.push(specifier);
}
}
if (values.length === 0 || types.length === 0) {
return [node];
}
const valueImport = ts.factory.createImportDeclaration(
undefined,
ts.factory.createImportClause(false, undefined, ts.factory.createNamedImports(values)),
node.moduleSpecifier
);
const typeImport = ts.factory.createImportDeclaration(
undefined,
ts.factory.createImportClause(true, undefined, ts.factory.createNamedImports(types)),
node.moduleSpecifier
);
return [valueImport, typeImport];
}The codemod runs in three passes. The first pass identifies all mixed imports. The second pass splits them into separate statements. The third pass verifies that the emitted JavaScript matches the original output. The verification step catches edge cases where the split changes runtime behavior.
The migration also requires updating barrel files. Instead of re-exporting everything with export *, the file must list each export explicitly. This is verbose but makes the intent clear:
// Before: ambiguous re-export
export * from "./user";
// After: explicit separation
export { createUser, updateUser, deleteUser } from "./user";
export type { User, UserRole, UserPreferences } from "./user";The pattern extends to default exports. A mixed statement like export { default as User, type UserRole } from "./user" must split into two lines. The migration is tedious, but it eliminates ambiguity.
ESLint Rules vs Compiler Enforcement: What Changed
Before verbatimModuleSyntax, teams relied on ESLint rules to enforce import discipline. The @typescript-eslint/consistent-type-imports rule warned when an import statement mixed types and values, but it could not enforce correctness at build time. The compiler still accepted mixed imports and guessed which symbols to elide.
%% alt: Comparison of ESLint vs compiler enforcement
flowchart LR
subgraph ESLint["ESLint Rule (Pre-6.0)"]
A("Mixed import<br/>detected") --> B("Warning logged<br/>in IDE")
B --> C("Code still<br/>compiles")
end
subgraph Compiler["verbatimModuleSyntax (6.0+)"]
D("Mixed import<br/>detected") --> E("Compilation<br/>fails")
E --> F("Build breaks<br/>immediately")
end
style C stroke:#fbbf24,fill:#3a2f0b,color:#fef3c7
style F stroke:#ef4444,fill:#450a0a,color:#fca5a5
The difference is enforcement. ESLint rules are advisory. Developers can ignore warnings, disable rules locally, or configure the linter to skip certain files. The compiler is absolute. If the code violates verbatimModuleSyntax, the build fails. There is no workaround short of disabling the flag.
This shift breaks workflows that depend on gradual migration. A team might enable the ESLint rule in new code while allowing violations in legacy modules. With verbatimModuleSyntax, that approach fails. The entire codebase must comply or the build stops.
The implication here is that teams must choose between strict enforcement and incremental adoption. The compiler offers no middle ground. This is intentional. The flag exists to eliminate ambiguity, and ambiguity is binary: either the import is explicit, or it is not.
The ESLint rule still provides value during migration. Running eslint --fix with @typescript-eslint/consistent-type-imports enabled rewrites most mixed imports automatically. The linter handles the mechanical work, and the compiler verifies correctness. Teams that combine both tools complete the migration faster.
Side Effects, Re-exports, and Edge Cases That Still Break
Side-effect imports fail silently under verbatimModuleSyntax unless the module declares its side effects in package.json. A statement like import "./setup-logging" compiles cleanly, but the emitted JavaScript might exclude the import if the bundler assumes it is dead code.
The fix requires one of two approaches. First, the module can declare "sideEffects": ["./setup-logging.js"] in package.json. This signals to bundlers that the module must execute even if no symbols are imported. Second, the import can use explicit syntax: import "./setup-logging" remains as-is, but the module must export a dummy symbol to signal intent.
// setup-logging.ts
export const __setupLogging = true;
// main.ts
import "./setup-logging"; // Fails silently under verbatimModuleSyntax
// Better: import the dummy export
import { __setupLogging } from "./setup-logging";The dummy export approach is fragile. If a refactor removes the symbol, the import breaks. The package.json approach is more robust but requires coordination between the TypeScript codebase and the build configuration.
Re-exports of type-only modules fail unless marked explicitly. A barrel file that re-exports from a module containing only types must use export type *:
// user-types.ts
export type User = { id: string; name: string };
export type Role = "admin" | "user";
// index.ts (wrong)
export * from "./user-types"; // Error: module has no runtime exports
// index.ts (correct)
export type * from "./user-types";The error occurs because export * implies runtime re-exports, but the target module contains only types. The compiler throws error TS2305 because it cannot emit JavaScript for a type-only re-export without the type keyword.
Namespace imports create another edge case. A statement like import * as User from "./user" fails under verbatimModuleSyntax if the module exports only types. The fix requires import type * as User from "./user", but this breaks code that expects a runtime namespace object.
// Before: namespace import works implicitly
import * as User from "./user";
type AdminUser = User.User & { role: "admin" };
// After: must mark as type-only
import type * as User from "./user";
type AdminUser = User.User & { role: "admin" };The failure mode here is that the namespace import disappears during emit. If the code uses User in a runtime expression, the build breaks. The compiler flags this as error TS2693: "'User' only refers to a type, but is being used as a value here."
Production Impact: Build Performance and Bundle Size
Enabling verbatimModuleSyntax improves build performance by eliminating the compiler's usage analysis. In a codebase with 50,000 imports, the compiler spends 10-15% of its time determining which imports are types and which are values. When every import is explicit, the compiler skips that analysis entirely.
%% alt: Build performance improvement flow
flowchart LR
A("TypeScript starts<br/>compilation") --> B("Parse import<br/>statements")
B --> C{"verbatimModuleSyntax<br/>enabled?"}
C -->|No| D("Analyze usage<br/>context (+15% time)")
C -->|Yes| E("Trust explicit<br/>syntax (skip)")
D --> F("Emit JavaScript")
E --> F
style E stroke:#c084fc,fill:#3b0764,color:#f3e8ff,stroke-width:4px
style F stroke:#34d399,fill:#0b3b2e,color:#d1fae5
The performance gain scales with codebase size. A project with 100,000 lines of TypeScript sees a 5% reduction in compile time. A monorepo with 1,000,000 lines sees 15-30% faster builds. The improvement comes from skipping the type-checking pass that determines whether each import is used in a runtime context.
Bundle size also decreases because bundlers no longer parse elided imports. When the compiler emits import type { User } from "./user", the bundler knows immediately that the import is type-only and skips it during dead-code elimination. Without explicit syntax, the bundler must parse the module to determine whether User is used at runtime.
The impact is measurable. A production build of a 500KB TypeScript bundle drops to 480KB with verbatimModuleSyntax enabled. The reduction comes from eliminating unused imports that the compiler previously emitted because it guessed wrong about their usage.
This matters because build performance and bundle size compound in CI/CD pipelines. A 15% faster build saves 90 seconds on a 10-minute pipeline. Over hundreds of builds per day, the savings add up to hours of compute time.
The tradeoff is migration effort. Teams must weigh the upfront cost of splitting mixed imports against the ongoing benefit of faster builds. For large codebases, the break-even point is typically 2-3 months after enabling the flag.
Frequently Asked Questions
Does verbatimModuleSyntax break compatibility with older TypeScript versions?
No, but it requires TypeScript 5.0 or later. Codebases that enable the flag cannot downgrade to 4.x without removing it from tsconfig.json.
Can I enable verbatimModuleSyntax incrementally across a monorepo?
No. The flag applies to the entire project. Teams must migrate all packages before enabling it, or the build fails across the monorepo.
What happens if a third-party library violates verbatimModuleSyntax?
The compiler throws errors on import statements from that library. The fix requires submitting a PR to the library or forking it to add explicit import type syntax.
Does verbatimModuleSyntax affect runtime performance?
No. The flag only changes compile-time behavior. The emitted JavaScript is identical to what the compiler would produce with correct manual annotations.
Should new projects enable verbatimModuleSyntax by default?
Yes. The flag eliminates ambiguity and improves build performance with no downside for greenfield codebases. Existing projects face migration effort but gain long-term benefits.
Conclusion: Should You Enable It in 2026?
The decision to enable verbatimModuleSyntax depends on codebase size and team tolerance for migration churn. Greenfield projects should enable it from day one. The flag enforces discipline without migration cost, and it prevents the import ambiguity that breaks builds later.
Existing codebases face a tradeoff. The migration effort scales linearly with import count, but the build performance gain scales with compile time. A project that compiles in 30 seconds sees minimal benefit. A project that compiles in 10 minutes saves hours of CI/CD time per week.
Teams that adopt the flag should plan for a two-phase migration. First, run the ESLint rule with auto-fix to split mixed imports. Second, enable verbatimModuleSyntax and address the remaining failures manually. The process takes days for small codebases and weeks for large monorepos, but the result is a build that never guesses about import intent.
That covers the essential patterns for verbatimModuleSyntax enforcement in TypeScript 6.0. Apply these in production and the difference will be immediate.